remotedesktop 1.5.0__tar.gz → 1.7.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/CLAUDE.md +4 -1
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/PKG-INFO +2 -1
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/pyproject.toml +1 -0
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/src/remotedesktop/__init__.py +1 -1
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/src/remotedesktop/app.py +44 -0
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/src/remotedesktop/config.py +138 -122
- remotedesktop-1.7.0/src/remotedesktop/cursor_shape.py +89 -0
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/src/remotedesktop/preferences.py +103 -10
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/src/remotedesktop/server.py +9 -7
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/src/remotedesktop/sharing.py +36 -0
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/src/remotedesktop/viewer.py +44 -0
- remotedesktop-1.7.0/src/remotedesktop/visual_effects.py +182 -0
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/tests/test_config.py +79 -58
- remotedesktop-1.7.0/tests/test_cursor_shape.py +20 -0
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/tests/test_input.py +15 -0
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/tests/test_main_window.py +68 -1
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/tests/test_preferences.py +31 -0
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/tests/test_sharing.py +36 -1
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/tests/test_smoke.py +35 -0
- remotedesktop-1.7.0/tests/test_visual_effects.py +125 -0
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/uv.lock +4 -2
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/.github/workflows/ci.yml +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/.github/workflows/release.yml +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/.gitignore +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/LICENSE +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/docs/media/client-demo.gif +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/docs/media/server-demo.gif +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/icon/remotedesktop.ico +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/readme.md +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/run.bat +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/run_claude.bat +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/scripts/make_coverage_badge.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/scripts/make_installer.bat +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/scripts/make_venv.bat +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/src/remotedesktop/__main__.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/src/remotedesktop/about.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/src/remotedesktop/autostart.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/src/remotedesktop/client.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/src/remotedesktop/clipboard.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/src/remotedesktop/compat.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/src/remotedesktop/db.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/src/remotedesktop/discovery.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/src/remotedesktop/dxgi.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/src/remotedesktop/frames.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/src/remotedesktop/icon.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/src/remotedesktop/input_injection.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/src/remotedesktop/inventory.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/src/remotedesktop/logs.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/src/remotedesktop/modal_loop.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/src/remotedesktop/performance.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/src/remotedesktop/protocol.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/src/remotedesktop/single_instance.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/src/remotedesktop/tls.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/src/remotedesktop/window_state.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/tests/conftest.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/tests/test_about.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/tests/test_autostart.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/tests/test_clipboard.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/tests/test_compat.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/tests/test_discovery.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/tests/test_dxgi.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/tests/test_frames.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/tests/test_icon.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/tests/test_input_injection.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/tests/test_inventory.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/tests/test_logs.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/tests/test_modal_loop.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/tests/test_performance.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/tests/test_protocol.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/tests/test_sharing_tab.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/tests/test_single_instance.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/tests/test_tls.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/tests/test_window_state.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/tools/make_demo_gifs.py +0 -0
- {remotedesktop-1.5.0 → remotedesktop-1.7.0}/tools/make_icon.py +0 -0
|
@@ -76,17 +76,20 @@ Managed with `uv` (hatchling build backend, src layout):
|
|
|
76
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.
|
|
77
77
|
- **Menus & shortcuts contract** (`app.py` `_build_menus`, `viewer.py`): File/View/Help menu bar; shortcuts Ctrl+W (close session tab), Ctrl+Q (quit), F5 (rescan), F11 (fullscreen). While a viewer shows a frame it **accepts `ShortcutOverride`** so every key forwards to the remote machine — **F11 is the single reserved local key**. Shortcut actions are also `addAction`-ed on the window so they fire with the menu bar hidden (fullscreen). Keep every menu/action as an attribute — PySide6 gives Python ownership of `addMenu`/`addAction` (and `QAction.menu()`) wrappers, and a GC'd local deletes the C++ object. Fullscreen hides menu/status bars, dock, and tab bar and restores them (including pre-fullscreen dock visibility) on exit; `closeEvent` exits fullscreen before persisting layout. Window geometry AND dock layout persist (`window_state.save_state`/`restore_state`, key `main_window_state`; the dock needs its objectName).
|
|
78
78
|
- **Privacy toggles**: Preferences' "Sync clipboard" checkbox (`clipboard_sync_enabled`, default on) drives `ClipboardSync.enabled` — while off, local copies aren't sent and peer payloads aren't applied. View-only sharing is the "view" mode of the Screen sharing radios (`ShareServer.set_input_allowed` — a server-side drop, no wire change, that releases held input when switched off). Quitting with ≥1 viewer connected asks for confirmation (declining resets `_quitting`); restart shuts sharing down first so it never double-prompts.
|
|
79
|
+
- **Reduce visual effects while sharing** (`visual_effects.py`): animations/fades are a burst of changed frames for the lossless pipeline, so while the preference is on (default on, `reduce_effects_enabled`, `load_reduce_effects_enabled`) **and ≥1 viewer is connected**, `MainWindow` applies `VisualEffectsReducer` and restores it when the last viewer leaves (driven by `SharingTab.viewerCountChanged`; explicit safety-net restore in `closeEvent`). The reduced set: SPI_SETUIEFFECTS off, drop shadows off, client-area + min/max animations off, submenu delay lowered to 50 ms (never raised). All via `SystemParametersInfo` **without** SPIF_UPDATEINIFILE — session-scoped, never persisted, self-heals at logoff; knobs already at the reduced value are left untouched so the user's own choices are never "restored" over. The backend (`SpiBackend`) is injectable — **tests must always inject `FakeSpiBackend`** (the preference defaults on, so a real reducer in a test would change the host's actual Windows settings; `make_window` in `tests/test_main_window.py` injects it for every window test, and real-backend tests may only read).
|
|
80
|
+
- **Theme preference** (`preferences.py`): a three-radio group in Preferences — follow the Windows light/dark setting (default) / Light / Dark — persisted as `THEME_KEY` (`"system"`/`"light"`/`"dark"`, `load_theme` falls back to system on junk). `apply_theme` drives `QStyleHints.setColorScheme` (`unsetColorScheme` for system, so OS-side switches keep applying); the radios apply live and `MainWindow.__init__` re-applies the persisted choice before building widgets. Tests that set a non-system theme must restore with `apply_theme(THEME_SYSTEM)` — the color scheme is process-global QApplication state.
|
|
79
81
|
- **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.
|
|
80
82
|
- **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).
|
|
81
83
|
- 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.
|
|
82
84
|
- **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).
|
|
83
|
-
- **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**.
|
|
85
|
+
- **All persistence is one SQLite database** (`db.py`): `db.connect(path)` opens the DB and ensures every table (`settings`, `paired_clients`, `known_servers`, and the inventory tables). The file lives at `config.default_db_path()` — `platformdirs.user_data_dir("remotedesktop")` (`%LOCALAPPDATA%\remotedesktop\remotedesktop.db` on Windows). **Use `platformdirs` for any new data location; don't hardcode `%APPDATA%`.** Every store (`Settings`, `PairedClients`, `KnownServers`, `ConnectionInventory`) takes a `sqlite3.Connection`; the app opens one connection and shares it. **Boolean settings go through `Settings.get_bool`/`set_bool`** (the `tobool` package's `to_bool_strict`; on-disk form stays the historical `"1"`/`"0"`, junk falls back to the caller's default) — don't hand-roll `!= "0"` comparisons. This applies to binary values stored as strings/ints; ctypes Win32 `BOOL`s keep plain `bool()` (TRUE is "any nonzero", which strict parsing correctly refuses). `MainWindow` takes a `connection=` keyword so tests inject a temp-file DB and never touch the real one (see `tests/test_main_window.py` / `test_sharing_tab.py`). Tests pass `db.connect(tmp_path/"x.db")` (or omit for in-memory), and use **separate DB files per simulated machine**.
|
|
84
86
|
- **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.
|
|
85
87
|
- **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.
|
|
86
88
|
- **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.
|
|
87
89
|
- **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).
|
|
88
90
|
- 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.
|
|
89
91
|
- **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.
|
|
92
|
+
- **Remote cursor shape** (`cursor_shape.py`): the captured frame never contains the mouse pointer (DXGI duplication and BitBlt both exclude it), so the client mirrors the server's cursor on its *local* cursor instead. `ShareServer` takes an opt-in `cursor_probe=` callable (the `clipboard=` pattern — only the GUI passes one, `SharingTab` wires `current_cursor_shape`: GetCursorInfo compared against the shared standard IDC_* handles; custom cursors → "arrow", not showing → "hidden"). The probe is polled each broadcast tick and `{"type": "cursor", "shape": ...}` is sent on change only, plus once to each just-admitted stream. `ShareClient.cursorShapeChanged` → `ViewerWidget.set_remote_cursor` maps names to Qt cursors (unknown names → arrow); `clear()` unsets the cursor. Peers without the feature never send / silently ignore the message.
|
|
90
93
|
- **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.
|
|
91
94
|
- **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`.
|
|
92
95
|
- **Injection is isolated behind `InputInjector`** (`input_injection.py`, Windows `SendInput` via ctypes; inert stub off-Windows). ShareServer takes an `injector=` param so tests pass a recording fake — **never let tests construct a real `InputInjector`, or they will move the host's actual mouse/keyboard.** Normalized 0..1 coords map directly to SendInput's 0..65535 absolute range over the primary monitor.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: remotedesktop
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.7.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
|
|
@@ -19,6 +19,7 @@ Requires-Dist: cryptography>=49.0.0
|
|
|
19
19
|
Requires-Dist: platformdirs>=4.10.0
|
|
20
20
|
Requires-Dist: pyside6>=6.11.1
|
|
21
21
|
Requires-Dist: semver>=3.0
|
|
22
|
+
Requires-Dist: tobool>=0.4.0
|
|
22
23
|
Description-Content-Type: text/markdown
|
|
23
24
|
|
|
24
25
|
# Remote Desktop
|
|
@@ -52,14 +52,18 @@ from remotedesktop.modal_loop import HTCLOSE, HTMAXBUTTON, HTMINBUTTON, ModalLoo
|
|
|
52
52
|
from remotedesktop.performance import PerformanceMonitor, PerformanceTab
|
|
53
53
|
from remotedesktop.preferences import (
|
|
54
54
|
PreferencesTab,
|
|
55
|
+
apply_theme,
|
|
55
56
|
load_clipboard_sync_enabled,
|
|
56
57
|
load_performance_window_seconds,
|
|
58
|
+
load_reduce_effects_enabled,
|
|
59
|
+
load_theme,
|
|
57
60
|
load_viewer_enabled,
|
|
58
61
|
)
|
|
59
62
|
from remotedesktop.server import SharingTab, ViewersTable
|
|
60
63
|
from remotedesktop.sharing import ShareClient
|
|
61
64
|
from remotedesktop.single_instance import SingleInstance
|
|
62
65
|
from remotedesktop.viewer import ViewerWidget
|
|
66
|
+
from remotedesktop.visual_effects import VisualEffectsReducer
|
|
63
67
|
|
|
64
68
|
_log = logging.getLogger("remotedesktop.app")
|
|
65
69
|
|
|
@@ -125,6 +129,7 @@ class MainWindow(QMainWindow):
|
|
|
125
129
|
connect_port: int = DEFAULT_CONNECT_PORT,
|
|
126
130
|
tray_available: bool | None = None,
|
|
127
131
|
reconnect_base_seconds: float = 2.0,
|
|
132
|
+
effects_reducer: VisualEffectsReducer | None = None,
|
|
128
133
|
) -> None:
|
|
129
134
|
super().__init__()
|
|
130
135
|
self._reconnect_base = reconnect_base_seconds
|
|
@@ -136,6 +141,9 @@ class MainWindow(QMainWindow):
|
|
|
136
141
|
# The viewer (client) role is opt-out: a dedicated server turns it
|
|
137
142
|
# off and loses the client-side UI (Server tab, discovery, history).
|
|
138
143
|
self._viewer_enabled = load_viewer_enabled(self._settings)
|
|
144
|
+
# Restore the persisted theme before any widgets are built, so the
|
|
145
|
+
# window never flashes in the wrong scheme.
|
|
146
|
+
apply_theme(load_theme(self._settings))
|
|
139
147
|
# One monitor per role: ShareServer.close() resets its monitor, so
|
|
140
148
|
# toggling sharing must not share a monitor with the viewing sessions
|
|
141
149
|
# (and vice versa for _connect_session's reset).
|
|
@@ -159,6 +167,15 @@ class MainWindow(QMainWindow):
|
|
|
159
167
|
self._tray: QSystemTrayIcon | None = None
|
|
160
168
|
self._tray_notified = False
|
|
161
169
|
self._quitting = False
|
|
170
|
+
# Windows visual effects are reduced only while someone is actually
|
|
171
|
+
# watching (fewer animation frames to encode and ship) and restored
|
|
172
|
+
# when the last viewer leaves. The default reducer changes real OS
|
|
173
|
+
# settings — tests always inject one with a fake backend.
|
|
174
|
+
self._effects_reducer = (
|
|
175
|
+
effects_reducer if effects_reducer is not None else VisualEffectsReducer()
|
|
176
|
+
)
|
|
177
|
+
self._reduce_effects = load_reduce_effects_enabled(self._settings)
|
|
178
|
+
self._sharing_viewer_count = 0
|
|
162
179
|
self._fullscreen_state: dict | None = None
|
|
163
180
|
self._fullscreen_hint: QLabel | None = None
|
|
164
181
|
|
|
@@ -175,6 +192,7 @@ class MainWindow(QMainWindow):
|
|
|
175
192
|
self.sharing_tab.peerEvent.connect(self._record_server_peer)
|
|
176
193
|
self.sharing_tab.sharingChanged.connect(self._on_sharing_changed)
|
|
177
194
|
self.sharing_tab.viewerCountChanged.connect(self._update_sharing_indicator)
|
|
195
|
+
self.sharing_tab.viewerCountChanged.connect(self._on_viewer_count_changed)
|
|
178
196
|
|
|
179
197
|
# One tab per server connection (inserted at the front, closable),
|
|
180
198
|
# followed by the fixed tabs, which never get a close button. While
|
|
@@ -333,6 +351,7 @@ class MainWindow(QMainWindow):
|
|
|
333
351
|
# three-state choice) and the viewer role's UI.
|
|
334
352
|
self.preferences_tab.sharingModeChanged.connect(self.sharing_tab.set_mode)
|
|
335
353
|
self.preferences_tab.viewerModeChanged.connect(self._set_viewer_enabled)
|
|
354
|
+
self.preferences_tab.reduceEffectsChanged.connect(self._on_reduce_effects_changed)
|
|
336
355
|
self.preferences_tab.restart_button.clicked.connect(self._restart_app)
|
|
337
356
|
self._tabs.addTab(self.preferences_tab, "Preferences")
|
|
338
357
|
self._about_tab = AboutTab()
|
|
@@ -708,6 +727,27 @@ class MainWindow(QMainWindow):
|
|
|
708
727
|
self._update_performance_tabs()
|
|
709
728
|
self._update_connections_groups()
|
|
710
729
|
|
|
730
|
+
def _on_viewer_count_changed(self, count: int) -> None:
|
|
731
|
+
self._sharing_viewer_count = count
|
|
732
|
+
self._update_effects_reduction()
|
|
733
|
+
|
|
734
|
+
def _on_reduce_effects_changed(self, enabled: bool) -> None:
|
|
735
|
+
self._reduce_effects = enabled
|
|
736
|
+
self._update_effects_reduction() # applies/restores live mid-session
|
|
737
|
+
|
|
738
|
+
def _update_effects_reduction(self) -> None:
|
|
739
|
+
"""Reduce Windows visual effects while the preference is on AND a
|
|
740
|
+
viewer is connected; restore the user's values otherwise. Stop,
|
|
741
|
+
quit, and restart all reach here via their viewerCountChanged(0)."""
|
|
742
|
+
if self._reduce_effects and self._sharing_viewer_count > 0:
|
|
743
|
+
if self._effects_reducer.apply():
|
|
744
|
+
self.log(
|
|
745
|
+
"Windows visual effects reduced while viewers are connected "
|
|
746
|
+
"(restored when the last one disconnects)"
|
|
747
|
+
)
|
|
748
|
+
elif self._effects_reducer.restore():
|
|
749
|
+
self.log("Windows visual effects restored")
|
|
750
|
+
|
|
711
751
|
def _quit(self) -> None:
|
|
712
752
|
self._quitting = True
|
|
713
753
|
self.close()
|
|
@@ -753,6 +793,9 @@ class MainWindow(QMainWindow):
|
|
|
753
793
|
self._cancel_reconnect(session)
|
|
754
794
|
session.client.close()
|
|
755
795
|
self.sharing_tab.shutdown()
|
|
796
|
+
# shutdown's viewerCountChanged(0) already restored the effects;
|
|
797
|
+
# this is the safety net for any exit path that skipped the signal.
|
|
798
|
+
self._effects_reducer.restore()
|
|
756
799
|
super().closeEvent(event)
|
|
757
800
|
QApplication.quit() # main() disables quit-on-last-window-closed
|
|
758
801
|
|
|
@@ -996,6 +1039,7 @@ class MainWindow(QMainWindow):
|
|
|
996
1039
|
client.denied.connect(lambda reason, s=session: self._on_denied(s, reason))
|
|
997
1040
|
client.disconnected.connect(lambda s=session: self._on_disconnected(s))
|
|
998
1041
|
client.frameReceived.connect(lambda image, s=session: self._on_frame(s, image))
|
|
1042
|
+
client.cursorShapeChanged.connect(lambda shape, s=session: s.viewer.set_remote_cursor(shape))
|
|
999
1043
|
client.logReceived.connect(lambda text, s=session: self._show_server_log(s.name, text))
|
|
1000
1044
|
client.connectionFailed.connect(lambda _reason, s=session: self._schedule_reconnect(s))
|
|
1001
1045
|
self._sessions.append(session)
|
|
@@ -1,122 +1,138 @@
|
|
|
1
|
-
"""Persistent per-machine state, all stored in the shared SQLite database.
|
|
2
|
-
|
|
3
|
-
`Settings` is a generic key/value store (and holds this client's stable id and
|
|
4
|
-
name). `PairedClients` maps each approved client id to the token issued at
|
|
5
|
-
approval (server side). `KnownServers` records the servers this machine has
|
|
6
|
-
paired with — pinned fingerprint and token — keyed by "host:port" (client
|
|
7
|
-
side). All three operate on a `sqlite3.Connection` from `db.connect`.
|
|
8
|
-
"""
|
|
9
|
-
|
|
10
|
-
import socket
|
|
11
|
-
import sqlite3
|
|
12
|
-
import uuid
|
|
13
|
-
from pathlib import Path
|
|
14
|
-
|
|
15
|
-
import platformdirs
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
"
|
|
42
|
-
(key
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
self._db
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
self._db.
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
self._db.execute(
|
|
122
|
-
|
|
1
|
+
"""Persistent per-machine state, all stored in the shared SQLite database.
|
|
2
|
+
|
|
3
|
+
`Settings` is a generic key/value store (and holds this client's stable id and
|
|
4
|
+
name). `PairedClients` maps each approved client id to the token issued at
|
|
5
|
+
approval (server side). `KnownServers` records the servers this machine has
|
|
6
|
+
paired with — pinned fingerprint and token — keyed by "host:port" (client
|
|
7
|
+
side). All three operate on a `sqlite3.Connection` from `db.connect`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import socket
|
|
11
|
+
import sqlite3
|
|
12
|
+
import uuid
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import platformdirs
|
|
16
|
+
from tobool import to_bool_strict
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def default_config_dir() -> Path:
|
|
20
|
+
return Path(platformdirs.user_data_dir("remotedesktop", appauthor=False))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def default_db_path() -> Path:
|
|
24
|
+
return default_config_dir() / "remotedesktop.db"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Settings:
|
|
28
|
+
"""A key/value store over the `settings` table."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, connection: sqlite3.Connection) -> None:
|
|
31
|
+
self._db = connection
|
|
32
|
+
|
|
33
|
+
def get(self, key: str, default: str | None = None) -> str | None:
|
|
34
|
+
row = self._db.execute(
|
|
35
|
+
"SELECT value FROM settings WHERE key = ?", (key,)
|
|
36
|
+
).fetchone()
|
|
37
|
+
return row[0] if row is not None else default
|
|
38
|
+
|
|
39
|
+
def set(self, key: str, value: str) -> None:
|
|
40
|
+
self._db.execute(
|
|
41
|
+
"INSERT INTO settings (key, value) VALUES (?, ?) "
|
|
42
|
+
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
|
43
|
+
(key, value),
|
|
44
|
+
)
|
|
45
|
+
self._db.commit()
|
|
46
|
+
|
|
47
|
+
def get_bool(self, key: str, default: bool = False) -> bool:
|
|
48
|
+
"""A boolean setting, stored as "1"/"0" (the historical on-disk
|
|
49
|
+
form). Anything unparseable falls back to the default — settings
|
|
50
|
+
are data, and junk must never take the app down."""
|
|
51
|
+
value = self.get(key)
|
|
52
|
+
if value is None:
|
|
53
|
+
return default
|
|
54
|
+
try:
|
|
55
|
+
return to_bool_strict(value)
|
|
56
|
+
except ValueError:
|
|
57
|
+
return default
|
|
58
|
+
|
|
59
|
+
def set_bool(self, key: str, value: bool) -> None:
|
|
60
|
+
self.set(key, "1" if value else "0")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def load_client_identity(connection: sqlite3.Connection) -> tuple[str, str]:
|
|
64
|
+
"""Return this client's (stable id, display name), creating it on first use."""
|
|
65
|
+
settings = Settings(connection)
|
|
66
|
+
client_id = settings.get("client_id")
|
|
67
|
+
name = settings.get("client_name")
|
|
68
|
+
if client_id and name:
|
|
69
|
+
return client_id, name
|
|
70
|
+
client_id = str(uuid.uuid4())
|
|
71
|
+
name = socket.gethostname()
|
|
72
|
+
settings.set("client_id", client_id)
|
|
73
|
+
settings.set("client_name", name)
|
|
74
|
+
return client_id, name
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class PairedClients:
|
|
78
|
+
"""Maps each approved client id to its shared token (server side).
|
|
79
|
+
|
|
80
|
+
A client is "approved" exactly when it has a token here.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def __init__(self, connection: sqlite3.Connection) -> None:
|
|
84
|
+
self._db = connection
|
|
85
|
+
|
|
86
|
+
def __contains__(self, client_id: str) -> bool:
|
|
87
|
+
return self.token_for(client_id) is not None
|
|
88
|
+
|
|
89
|
+
def token_for(self, client_id: str) -> str | None:
|
|
90
|
+
row = self._db.execute(
|
|
91
|
+
"SELECT token FROM paired_clients WHERE client_id = ?", (client_id,)
|
|
92
|
+
).fetchone()
|
|
93
|
+
return row[0] if row is not None else None
|
|
94
|
+
|
|
95
|
+
def pair(self, client_id: str) -> str:
|
|
96
|
+
"""Issue and persist a new token for a client, returning it."""
|
|
97
|
+
import secrets
|
|
98
|
+
|
|
99
|
+
token = secrets.token_hex(32)
|
|
100
|
+
self._db.execute(
|
|
101
|
+
"INSERT INTO paired_clients (client_id, token) VALUES (?, ?) "
|
|
102
|
+
"ON CONFLICT(client_id) DO UPDATE SET token = excluded.token",
|
|
103
|
+
(client_id, token),
|
|
104
|
+
)
|
|
105
|
+
self._db.commit()
|
|
106
|
+
return token
|
|
107
|
+
|
|
108
|
+
def revoke(self, client_id: str) -> None:
|
|
109
|
+
"""Remove a client's token so it must be approved again to reconnect."""
|
|
110
|
+
self._db.execute("DELETE FROM paired_clients WHERE client_id = ?", (client_id,))
|
|
111
|
+
self._db.commit()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class KnownServers:
|
|
115
|
+
"""Client-side record of paired servers, keyed by "host:port"."""
|
|
116
|
+
|
|
117
|
+
def __init__(self, connection: sqlite3.Connection) -> None:
|
|
118
|
+
self._db = connection
|
|
119
|
+
|
|
120
|
+
def get(self, key: str) -> dict | None:
|
|
121
|
+
row = self._db.execute(
|
|
122
|
+
"SELECT fingerprint, token FROM known_servers WHERE key = ?", (key,)
|
|
123
|
+
).fetchone()
|
|
124
|
+
return {"fingerprint": row[0], "token": row[1]} if row is not None else None
|
|
125
|
+
|
|
126
|
+
def remember(self, key: str, fingerprint: str, token: str) -> None:
|
|
127
|
+
self._db.execute(
|
|
128
|
+
"INSERT INTO known_servers (key, fingerprint, token) VALUES (?, ?, ?) "
|
|
129
|
+
"ON CONFLICT(key) DO UPDATE SET fingerprint = excluded.fingerprint, "
|
|
130
|
+
"token = excluded.token",
|
|
131
|
+
(key, fingerprint, token),
|
|
132
|
+
)
|
|
133
|
+
self._db.commit()
|
|
134
|
+
|
|
135
|
+
def forget(self, key: str) -> None:
|
|
136
|
+
"""Drop a server's stored token and pin, so the next connection re-pairs."""
|
|
137
|
+
self._db.execute("DELETE FROM known_servers WHERE key = ?", (key,))
|
|
138
|
+
self._db.commit()
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Identify the shape of the mouse cursor currently shown (server side).
|
|
2
|
+
|
|
3
|
+
The captured frame deliberately contains no mouse pointer (both DXGI desktop
|
|
4
|
+
duplication and GDI BitBlt exclude it), so what the viewer's user sees over
|
|
5
|
+
the remote screen is their own local cursor. This module lets the server
|
|
6
|
+
report what shape that cursor should be — an I-beam over text, the resize
|
|
7
|
+
arrows over a window edge, and so on: GetCursorInfo returns the current
|
|
8
|
+
cursor handle, and the standard cursors are shared handles identical to what
|
|
9
|
+
LoadCursor(NULL, IDC_*) returns, so a plain handle comparison identifies
|
|
10
|
+
them. Custom application cursors fall back to "arrow"; a cursor that is not
|
|
11
|
+
showing at all reports "hidden".
|
|
12
|
+
|
|
13
|
+
On non-Windows platforms `current_cursor_shape()` returns None (the app
|
|
14
|
+
targets Windows).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import ctypes
|
|
18
|
+
import sys
|
|
19
|
+
from ctypes import wintypes
|
|
20
|
+
|
|
21
|
+
_IS_WINDOWS = sys.platform == "win32"
|
|
22
|
+
|
|
23
|
+
_CURSOR_SHOWING = 0x1
|
|
24
|
+
|
|
25
|
+
# Standard cursor resource ids (winuser.h IDC_*) -> wire shape names. These
|
|
26
|
+
# names are the protocol vocabulary: ViewerWidget maps them to Qt cursors.
|
|
27
|
+
_IDC_SHAPES = {
|
|
28
|
+
32512: "arrow", # IDC_ARROW
|
|
29
|
+
32513: "ibeam", # IDC_IBEAM
|
|
30
|
+
32514: "wait", # IDC_WAIT
|
|
31
|
+
32515: "cross", # IDC_CROSS
|
|
32
|
+
32516: "uparrow", # IDC_UPARROW
|
|
33
|
+
32642: "size_nwse", # IDC_SIZENWSE (diagonal resize ↘)
|
|
34
|
+
32643: "size_nesw", # IDC_SIZENESW (diagonal resize ↗)
|
|
35
|
+
32644: "size_we", # IDC_SIZEWE (horizontal resize)
|
|
36
|
+
32645: "size_ns", # IDC_SIZENS (vertical resize)
|
|
37
|
+
32646: "size_all", # IDC_SIZEALL
|
|
38
|
+
32648: "no", # IDC_NO
|
|
39
|
+
32649: "hand", # IDC_HAND
|
|
40
|
+
32650: "appstarting", # IDC_APPSTARTING
|
|
41
|
+
32651: "help", # IDC_HELP
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
SHAPE_NAMES = frozenset(_IDC_SHAPES.values()) | {"hidden"}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
if _IS_WINDOWS:
|
|
48
|
+
|
|
49
|
+
class _CURSORINFO(ctypes.Structure):
|
|
50
|
+
_fields_ = [
|
|
51
|
+
("cbSize", wintypes.DWORD),
|
|
52
|
+
("flags", wintypes.DWORD),
|
|
53
|
+
("hCursor", ctypes.c_void_p),
|
|
54
|
+
("ptScreenPos", wintypes.POINT),
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
_handle_shapes: dict[int, str] | None = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _standard_handles() -> dict[int, str]:
|
|
62
|
+
"""HCURSOR -> shape name for every standard cursor (loaded once; the
|
|
63
|
+
handles are shared and stable for the lifetime of the process)."""
|
|
64
|
+
global _handle_shapes
|
|
65
|
+
if _handle_shapes is None:
|
|
66
|
+
user32 = ctypes.windll.user32
|
|
67
|
+
user32.LoadCursorW.restype = ctypes.c_void_p
|
|
68
|
+
user32.LoadCursorW.argtypes = (ctypes.c_void_p, ctypes.c_void_p)
|
|
69
|
+
_handle_shapes = {}
|
|
70
|
+
for resource, name in _IDC_SHAPES.items():
|
|
71
|
+
# MAKEINTRESOURCE: the id passed as the pointer-sized name arg.
|
|
72
|
+
handle = user32.LoadCursorW(None, ctypes.c_void_p(resource))
|
|
73
|
+
if handle:
|
|
74
|
+
_handle_shapes[handle] = name
|
|
75
|
+
return _handle_shapes
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def current_cursor_shape() -> str | None:
|
|
79
|
+
"""The current cursor's shape name, "hidden" while no cursor is showing,
|
|
80
|
+
or None when it cannot be determined (non-Windows, API failure)."""
|
|
81
|
+
if not _IS_WINDOWS:
|
|
82
|
+
return None
|
|
83
|
+
info = _CURSORINFO()
|
|
84
|
+
info.cbSize = ctypes.sizeof(_CURSORINFO)
|
|
85
|
+
if not ctypes.windll.user32.GetCursorInfo(ctypes.byref(info)):
|
|
86
|
+
return None
|
|
87
|
+
if not info.flags & _CURSOR_SHOWING:
|
|
88
|
+
return "hidden"
|
|
89
|
+
return _standard_handles().get(info.hCursor or 0, "arrow")
|