muxplex-deck 0.5.3__tar.gz → 0.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. {muxplex_deck-0.5.3 → muxplex_deck-0.6.0}/PKG-INFO +1 -1
  2. {muxplex_deck-0.5.3 → muxplex_deck-0.6.0}/pyproject.toml +1 -1
  3. {muxplex_deck-0.5.3 → muxplex_deck-0.6.0}/src/deck_probe/main.py +6 -0
  4. muxplex_deck-0.6.0/src/muxplex_deck/_vendor/hidapi/LICENSE-bsd.txt +26 -0
  5. muxplex_deck-0.6.0/src/muxplex_deck/_vendor/hidapi/VERSION +31 -0
  6. muxplex_deck-0.6.0/src/muxplex_deck/_vendor/hidapi/x64/hidapi.dll +0 -0
  7. {muxplex_deck-0.5.3 → muxplex_deck-0.6.0}/src/muxplex_deck/cli.py +71 -5
  8. {muxplex_deck-0.5.3 → muxplex_deck-0.6.0}/src/muxplex_deck/device_real.py +13 -2
  9. muxplex_deck-0.6.0/src/muxplex_deck/hidapi_win.py +113 -0
  10. {muxplex_deck-0.5.3 → muxplex_deck-0.6.0}/src/muxplex_deck/hidhelp.py +146 -1
  11. {muxplex_deck-0.5.3 → muxplex_deck-0.6.0}/src/muxplex_deck/statusfile.py +39 -1
  12. {muxplex_deck-0.5.3 → muxplex_deck-0.6.0}/README.md +0 -0
  13. {muxplex_deck-0.5.3 → muxplex_deck-0.6.0}/src/deck_probe/__init__.py +0 -0
  14. {muxplex_deck-0.5.3 → muxplex_deck-0.6.0}/src/deck_probe/__main__.py +0 -0
  15. {muxplex_deck-0.5.3 → muxplex_deck-0.6.0}/src/deck_probe/capabilities.py +0 -0
  16. {muxplex_deck-0.5.3 → muxplex_deck-0.6.0}/src/deck_probe/events.py +0 -0
  17. {muxplex_deck-0.5.3 → muxplex_deck-0.6.0}/src/deck_probe/rendering.py +0 -0
  18. {muxplex_deck-0.5.3 → muxplex_deck-0.6.0}/src/muxplex_deck/__init__.py +0 -0
  19. {muxplex_deck-0.5.3 → muxplex_deck-0.6.0}/src/muxplex_deck/__main__.py +0 -0
  20. {muxplex_deck-0.5.3 → muxplex_deck-0.6.0}/src/muxplex_deck/attention.py +0 -0
  21. {muxplex_deck-0.5.3 → muxplex_deck-0.6.0}/src/muxplex_deck/config.py +0 -0
  22. {muxplex_deck-0.5.3 → muxplex_deck-0.6.0}/src/muxplex_deck/device.py +0 -0
  23. {muxplex_deck-0.5.3 → muxplex_deck-0.6.0}/src/muxplex_deck/emulator.py +0 -0
  24. {muxplex_deck-0.5.3 → muxplex_deck-0.6.0}/src/muxplex_deck/focus.py +0 -0
  25. {muxplex_deck-0.5.3 → muxplex_deck-0.6.0}/src/muxplex_deck/init_wizard.py +0 -0
  26. {muxplex_deck-0.5.3 → muxplex_deck-0.6.0}/src/muxplex_deck/interaction.py +0 -0
  27. {muxplex_deck-0.5.3 → muxplex_deck-0.6.0}/src/muxplex_deck/layout.py +0 -0
  28. {muxplex_deck-0.5.3 → muxplex_deck-0.6.0}/src/muxplex_deck/main.py +0 -0
  29. {muxplex_deck-0.5.3 → muxplex_deck-0.6.0}/src/muxplex_deck/rendering.py +0 -0
  30. {muxplex_deck-0.5.3 → muxplex_deck-0.6.0}/src/muxplex_deck/service.py +0 -0
  31. {muxplex_deck-0.5.3 → muxplex_deck-0.6.0}/src/muxplex_deck/usbnode.py +0 -0
  32. {muxplex_deck-0.5.3 → muxplex_deck-0.6.0}/src/muxplex_deck/views.py +0 -0
  33. {muxplex_deck-0.5.3 → muxplex_deck-0.6.0}/src/muxplex_deck/wsl.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: muxplex-deck
3
- Version: 0.5.3
3
+ Version: 0.6.0
4
4
  Summary: Hardware probe/diagnostic app for the Elgato Stream Deck+ (seed of the muxplex sidecar)
5
5
  Author: Amplifier
6
6
  Author-email: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "muxplex-deck"
3
- version = "0.5.3"
3
+ version = "0.6.0"
4
4
  description = "Hardware probe/diagnostic app for the Elgato Stream Deck+ (seed of the muxplex sidecar)"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -34,6 +34,8 @@ from StreamDeck.DeviceManager import DeviceManager, ProbeError
34
34
  from StreamDeck.Devices.StreamDeck import StreamDeck
35
35
  from StreamDeck.Transport.Transport import TransportError
36
36
 
37
+ from muxplex_deck import hidapi_win
38
+
37
39
  from . import capabilities, events
38
40
 
39
41
  logger = logging.getLogger("deck_probe")
@@ -146,6 +148,10 @@ def _install_signal_handler() -> threading.Event:
146
148
  def run() -> int:
147
149
  _configure_logging()
148
150
 
151
+ # No-op on non-Windows; on Windows this must run BEFORE DeviceManager()
152
+ # ever imports/loads the native library -- see hidapi_win's module
153
+ # docstring for why both of its mechanisms are required.
154
+ hidapi_win.ensure_hidapi()
149
155
  try:
150
156
  manager = DeviceManager()
151
157
  except ProbeError:
@@ -0,0 +1,26 @@
1
+ Copyright (c) 2010, Alan Ott, Signal 11 Software
2
+ All rights reserved.
3
+
4
+ Redistribution and use in source and binary forms, with or without
5
+ modification, are permitted provided that the following conditions are met:
6
+
7
+ * Redistributions of source code must retain the above copyright notice,
8
+ this list of conditions and the following disclaimer.
9
+ * Redistributions in binary form must reproduce the above copyright
10
+ notice, this list of conditions and the following disclaimer in the
11
+ documentation and/or other materials provided with the distribution.
12
+ * Neither the name of Signal 11 Software nor the names of its
13
+ contributors may be used to endorse or promote products derived from
14
+ this software without specific prior written permission.
15
+
16
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
17
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
18
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
19
+ ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
20
+ LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
21
+ CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
22
+ SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
23
+ INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
24
+ CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
25
+ ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
26
+ POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,31 @@
1
+ Upstream: libusb/hidapi
2
+ Tag: hidapi-0.15.0
3
+ Release: https://github.com/libusb/hidapi/releases/tag/hidapi-0.15.0
4
+ Asset: hidapi-win.zip
5
+ Asset URL: https://github.com/libusb/hidapi/releases/download/hidapi-0.15.0/hidapi-win.zip
6
+
7
+ sha256 (hidapi-win.zip): d18c43ec9506a2f6d7faa9c7e0a342c4b64fbae521b71b5d4ac0777fd24dda93
8
+ sha256 (x64/hidapi.dll): d4c05ba2138cb5259a7f796464b5eaa63b9f8f67bb5cb003f96989244ee01583
9
+
10
+ Extracted: x64/hidapi.dll only (166912 bytes). The x86 build, headers, and
11
+ .pdb/.lib files from the release asset are not vendored -- this project only
12
+ supports 64-bit Python on Windows (see WINDOWS_NATIVE_SPEC.md section 2.4).
13
+
14
+ License: BSD 3-clause (see LICENSE-bsd.txt in this directory), one of
15
+ HIDAPI's three available license options. Chosen because its one
16
+ redistribution obligation (reproduce the copyright notice) is trivially
17
+ satisfied and it does not affect muxplex-deck's own license, unlike the
18
+ GPLv3 option.
19
+
20
+ To bump this vendored DLL to a newer hidapi release:
21
+ 1. Download the new release's `hidapi-win.zip` asset from
22
+ https://github.com/libusb/hidapi/releases
23
+ 2. Verify its SHA-256 against the value published in that release's notes
24
+ (or compute it yourself and record it here -- never vendor an unverified
25
+ download).
26
+ 3. Extract `x64/hidapi.dll` and overwrite the file next to this one.
27
+ 4. Update the tag, asset URL, and both SHA-256 values above.
28
+ 5. Re-run the full test suite; nothing in this repo depends on the DLL's
29
+ internal behavior beyond "streamdeck's ctypes probe can load it", which
30
+ only real Windows hardware can confirm (see AGENTS.md / the sign-off
31
+ checklist in WINDOWS_NATIVE_SPEC.md section 9).
@@ -15,6 +15,7 @@ from __future__ import annotations
15
15
  import argparse
16
16
  import contextlib
17
17
  import json
18
+ import os
18
19
  import platform
19
20
  import shutil
20
21
  import subprocess
@@ -328,13 +329,30 @@ def check_config_file(config_path: str | None = None) -> tuple[str, str]:
328
329
 
329
330
 
330
331
  def check_federation_key(key_file: Path) -> tuple[str, str]:
331
- """Federation key presence + permission check. Never prints the key itself."""
332
+ """Federation key presence + permission check. Never prints the key itself.
333
+
334
+ On Windows, `Path.chmod()` only toggles the read-only bit -- it does not
335
+ restrict other users -- so `st_mode` reads back as a permissive value
336
+ (typically 0o666) no matter what actually protects the file (NTFS ACLs,
337
+ which are per-user profile directories by default). Warning on every
338
+ run and recommending a `chmod` that cannot fix anything would violate
339
+ this repo's own rule (AGENTS.md: never print a command that cannot work
340
+ on the machine you are printing it to) -- so Windows gets presence-only
341
+ reporting, no POSIX-mode judgment, and no ACL check (that would be new,
342
+ untested, platform-specific security code for a file already inside a
343
+ per-user profile directory; see WINDOWS_NATIVE_SPEC.md section 3.3).
344
+ """
332
345
  if not key_file.exists():
333
346
  return "warn", f"Federation key file not found: {key_file}"
334
347
  try:
335
348
  mode = key_file.stat().st_mode & 0o777
336
349
  except OSError as exc:
337
350
  return "warn", f"Could not stat federation key file {key_file}: {exc}"
351
+ if sys.platform == "win32":
352
+ return "ok", (
353
+ f"Federation key: {key_file} "
354
+ "(NTFS ACLs govern access here -- POSIX modes are not enforced)"
355
+ )
338
356
  if mode & 0o077:
339
357
  return "warn", (
340
358
  f"Federation key file {key_file} is readable by others "
@@ -394,6 +412,53 @@ def check_ca_file(ca_file: Path | None) -> tuple[str, str]:
394
412
  )
395
413
 
396
414
 
415
+ def check_hidapi_dll() -> tuple[str, str] | None:
416
+ """Windows-only: report which `hidapi.dll` streamdeck's loader will actually use.
417
+
418
+ Returns `None` on every other platform (nothing to check -- other OSes
419
+ use their own dynamic linker conventions and the check is simply
420
+ skipped, not appended to the doctor output).
421
+
422
+ `hidapi_win.ensure_hidapi()` can create a silent shadowing failure mode
423
+ if something earlier on `%PATH%` wins the load race (see that module's
424
+ docstring for the mechanism) -- a design that creates a failure mode
425
+ owes the user a way to see it, so this always prints a line on
426
+ Windows, not only when something is wrong (WINDOWS_NATIVE_SPEC.md
427
+ section 2.6).
428
+ """
429
+ if sys.platform != "win32":
430
+ return None
431
+
432
+ from . import hidapi_win
433
+
434
+ dll_dir = hidapi_win.ensure_hidapi()
435
+ if dll_dir is None:
436
+ return "warn", (
437
+ "hidapi.dll: vendored copy not found in this install (Windows "
438
+ "arm64, or a source checkout without the vendored binary) -- "
439
+ "see https://github.com/libusb/hidapi/releases"
440
+ )
441
+
442
+ vendored = str(hidapi_win.vendored_dll_path())
443
+ resolved = hidapi_win.resolved_library_path()
444
+ if resolved is None:
445
+ return "warn", (
446
+ f"hidapi.dll: vendored copy present at {vendored} but "
447
+ "ctypes.util.find_library('hidapi') did not resolve anything "
448
+ "(check %PATH%)"
449
+ )
450
+ # Case-insensitive: see hidhelp._windows_guidance()'s comment on the
451
+ # same comparison -- os.path.normcase only behaves case-insensitively
452
+ # when the running process actually IS Windows, not when sys.platform
453
+ # is merely monkeypatched (as tests, and this repo's Linux-only CI, do).
454
+ if resolved.lower() != vendored.lower():
455
+ return "warn", (
456
+ f"hidapi.dll: resolves to {resolved} -- NOT the vendored copy "
457
+ "(see the WIN-DLL-SHADOW guidance below)"
458
+ )
459
+ return "ok", f"hidapi.dll: resolves to the vendored copy ({resolved})"
460
+
461
+
397
462
  def probe_deck_status(manager: Any) -> dict:
398
463
  """Pure: given a `DeviceManager`-shaped object, find + describe the deck.
399
464
 
@@ -729,6 +794,11 @@ def doctor(config_path: str | None = None) -> int:
729
794
  ca_file = config_mod._expand(raw["ca_file"])
730
795
  checks.append(check_ca_file(ca_file))
731
796
 
797
+ # Windows-only; None (skipped, not appended) on every other platform.
798
+ hidapi_check = check_hidapi_dll()
799
+ if hidapi_check is not None:
800
+ checks.append(hidapi_check)
801
+
732
802
  # Environment guidance (WSL/usbipd/udev-liveness) BEFORE the device
733
803
  # checks -- it explains why the next line is about to warn. Returns []
734
804
  # on a healthy platform (macOS, or native Linux with udev running), so
@@ -978,8 +1048,6 @@ def _find_uv() -> str | None:
978
1048
 
979
1049
 
980
1050
  def _is_executable(path: str) -> bool:
981
- import os
982
-
983
1051
  return os.access(path, os.X_OK)
984
1052
 
985
1053
 
@@ -1069,8 +1137,6 @@ def update(*, force: bool = False) -> None:
1069
1137
  if was_active:
1070
1138
  print(" Stopping service...")
1071
1139
  if sys.platform == "darwin":
1072
- import os
1073
-
1074
1140
  uid = os.getuid()
1075
1141
  subprocess.run(
1076
1142
  ["launchctl", "bootout", f"gui/{uid}/com.muxplex-deck"],
@@ -19,6 +19,7 @@ from StreamDeck.DeviceManager import ProbeError as _LibProbeError
19
19
  from StreamDeck.Devices.StreamDeck import StreamDeck as _LibStreamDeck
20
20
  from StreamDeck.Transport.Transport import TransportError
21
21
 
22
+ from . import hidapi_win
22
23
  from .device import (
23
24
  DeckDevice,
24
25
  DeviceProbeError,
@@ -34,8 +35,13 @@ _MISSING_HIDAPI_MESSAGE = (
34
35
  "Install it for your platform, then try again:\n"
35
36
  " macOS: brew install hidapi\n"
36
37
  " Debian/Ubuntu: sudo apt install libhidapi-libusb0\n"
37
- " Windows: bundled with the 'streamdeck' wheel; if missing, install\n"
38
- " hidapi via your package manager of choice.\n"
38
+ " Windows: muxplex-deck bundles hidapi.dll -- if you're seeing this,\n"
39
+ " either the vendored copy is missing from this install\n"
40
+ " (arm64 Windows, or a source checkout without the binary),\n"
41
+ " or a different hidapi.dll earlier on %PATH% shadowed it.\n"
42
+ " Run 'muxplex-deck doctor' for which DLL actually resolved,\n"
43
+ " or get one yourself: https://github.com/libusb/hidapi/releases\n"
44
+ " (the loader also honors ./hidapi.dll in the current directory).\n"
39
45
  )
40
46
 
41
47
 
@@ -147,6 +153,11 @@ class RealDeviceManager:
147
153
  """
148
154
 
149
155
  def __init__(self) -> None:
156
+ # No-op on non-Windows; on Windows this must run BEFORE
157
+ # _LibDeviceManager() ever imports/loads the native library -- see
158
+ # hidapi_win's module docstring for why both of its mechanisms are
159
+ # required to make the vendored DLL win the load-path race.
160
+ hidapi_win.ensure_hidapi()
150
161
  try:
151
162
  self._manager = _LibDeviceManager()
152
163
  except _LibProbeError as exc:
@@ -0,0 +1,113 @@
1
+ """Windows-only: make the vendored `hidapi.dll` resolvable by streamdeck's loader.
2
+
3
+ `StreamDeck/Transport/LibUSBHIDAPI.py` probes for a native HIDAPI shared
4
+ library by name via ctypes -- Windows ships no such library, so without
5
+ this module a Windows user gets `ProbeError: ... Is the 'hidapi.dll'
6
+ library installed?` (see WINDOWS_NATIVE_SPEC.md section 2).
7
+
8
+ **The load-path trap this module exists to close** (verified by reading
9
+ CPython + streamdeck source, section 2.1 of the spec):
10
+
11
+ for lib_name in library_search_list: # streamdeck
12
+ found_lib = ctypes.util.find_library(name_no_ext) # <-- FIRST
13
+ HIDAPI_INSTANCE = ctypes.cdll.LoadLibrary(found_lib or lib_name)
14
+
15
+ `ctypes.util.find_library()` on Windows (the `nt` branch) searches
16
+ **`%PATH%` only** and returns the first match. `ctypes.CDLL`'s own loader
17
+ (reached when `find_library` returns `None`) uses
18
+ `LOAD_LIBRARY_SEARCH_DEFAULT_DIRS`, which honors `os.add_dll_directory()`
19
+ but does **not** consult `%PATH%`. Net effect: a stray or wrong-arch
20
+ `hidapi.dll` earlier on `%PATH%` wins over anything registered via
21
+ `add_dll_directory`, `LoadLibrary` on it fails, the bare `except: pass` in
22
+ streamdeck's probe swallows the failure, and the vendored DLL is never
23
+ tried at all. This is a silent failure mode and both mechanisms below are
24
+ required to close it -- neither one alone is sufficient.
25
+
26
+ No-op (returns `None`, touches nothing) on every non-Windows platform.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import os
32
+ import sys
33
+ from pathlib import Path
34
+
35
+ __all__ = ["ensure_hidapi", "resolved_library_path", "vendored_dll_path"]
36
+
37
+ # Holds the `os.add_dll_directory()` return value. That object's __del__
38
+ # un-registers the directory when garbage collected -- keeping it at module
39
+ # scope for the life of the process is what makes the registration stick.
40
+ _dll_directory_cookie: object | None = None
41
+
42
+
43
+ def vendored_dll_path() -> Path:
44
+ """Path where the vendored `x64/hidapi.dll` is expected, whether or not it exists.
45
+
46
+ A plain path computation, not an existence check -- callers that need
47
+ to know if it's actually there should check `.exists()` themselves or
48
+ call `ensure_hidapi()`, which does.
49
+ """
50
+ return Path(__file__).parent / "_vendor" / "hidapi" / "x64" / "hidapi.dll"
51
+
52
+
53
+ def ensure_hidapi() -> Path | None:
54
+ """Register the vendored HIDAPI directory so streamdeck's loader finds it.
55
+
56
+ No-op on non-Windows (returns `None` immediately, no filesystem or
57
+ environment changes). On `win32`:
58
+
59
+ - Returns `None` if the vendored DLL is absent (arm64 Windows, or a
60
+ source checkout that doesn't carry the binary blob) -- callers fall
61
+ back to guidance (see `hidhelp._windows_guidance`'s `WIN-NOHIDAPI`);
62
+ this function never raises.
63
+ - Otherwise registers the DLL's directory via `os.add_dll_directory()`
64
+ *and* prepends it to `%PATH%` (both are required -- see the module
65
+ docstring), then returns the directory.
66
+
67
+ Idempotent: safe to call from every entry point (`RealDeviceManager`,
68
+ `deck-probe`) without worrying about double-registration -- the
69
+ `add_dll_directory` cookie is only created once, and the `%PATH%`
70
+ prepend is skipped if our directory is already present.
71
+ """
72
+ global _dll_directory_cookie
73
+
74
+ if sys.platform != "win32":
75
+ return None
76
+
77
+ dll_path = vendored_dll_path()
78
+ if not dll_path.exists():
79
+ return None
80
+
81
+ dll_dir = dll_path.parent
82
+
83
+ if _dll_directory_cookie is None:
84
+ _dll_directory_cookie = os.add_dll_directory(str(dll_dir)) # type: ignore[attr-defined]
85
+
86
+ dir_str = str(dll_dir)
87
+ current_path = os.environ.get("PATH", "")
88
+ existing_entries = current_path.split(os.pathsep) if current_path else []
89
+ if dir_str not in existing_entries:
90
+ os.environ["PATH"] = (
91
+ f"{dir_str}{os.pathsep}{current_path}" if current_path else dir_str
92
+ )
93
+
94
+ return dll_dir
95
+
96
+
97
+ def resolved_library_path() -> str | None:
98
+ """What `ctypes.util.find_library("hidapi")` actually resolves to, right now.
99
+
100
+ `None` on non-Windows (nothing to resolve -- other platforms use their
101
+ own dynamic linker conventions) or if nothing on `%PATH%` matches.
102
+ Does **not** call `ensure_hidapi()` itself -- callers that want "as
103
+ streamdeck will actually see it" must call `ensure_hidapi()` first, so
104
+ the comparison reflects the same environment streamdeck's probe runs
105
+ in. This split (rather than folding the call in here) keeps this
106
+ function a pure read of current process state, easy to test without
107
+ also exercising the registration side effects.
108
+ """
109
+ if sys.platform != "win32":
110
+ return None
111
+ import ctypes.util
112
+
113
+ return ctypes.util.find_library("hidapi")
@@ -230,6 +230,149 @@ def impostor_message(paths: wsl.UsbipdPaths) -> str:
230
230
  )
231
231
 
232
232
 
233
+ # ---------------------------------------------------------------------------
234
+ # Native Windows guidance (WINDOWS_NATIVE_SPEC.md section 4) -- the win32
235
+ # counterpart of the WSL states above. Same contract: `_windows_guidance()`
236
+ # returns [] on a healthy box, so `doctor()`'s output stays unchanged there.
237
+ # ---------------------------------------------------------------------------
238
+
239
+ WIN_ELGATO_MESSAGE = (
240
+ "The official Elgato Stream Deck app is running and holds exclusive HID\n"
241
+ "access to the device -- muxplex-deck cannot open it while that app is\n"
242
+ "open. Close it (system tray -> Quit), then:\n"
243
+ " muxplex-deck service restart"
244
+ )
245
+
246
+ WIN_NOHIDAPI_MESSAGE = (
247
+ "The bundled HIDAPI library (hidapi.dll) is missing from this install --\n"
248
+ "this happens on Windows arm64 (no official prebuilt for it) or a source\n"
249
+ "checkout without the vendored binary. Get one yourself:\n"
250
+ " https://github.com/libusb/hidapi/releases\n"
251
+ "and either put hidapi.dll on your PATH, or drop it into the directory\n"
252
+ "you run muxplex-deck from (the loader also checks ./hidapi.dll)."
253
+ )
254
+
255
+
256
+ def win_usbip_message(device: wsl.UsbipdDevice) -> str:
257
+ return (
258
+ f"Stream Deck (BUSID {device.busid}, {device.vid_pid}) is currently handed to\n"
259
+ "WSL -- Windows cannot see it. No administrator rights needed:\n"
260
+ f" usbipd detach --busid {device.busid}\n"
261
+ "Then physically unplug and replug the deck. Verified: after detach, the\n"
262
+ "deck keeps rendering whatever WSL last drew, and native Windows cannot\n"
263
+ "claim it until it is power-cycled -- this is a real step, not a quirk."
264
+ )
265
+
266
+
267
+ def win_dll_shadow_message(resolved: str, vendored: str) -> str:
268
+ return (
269
+ "A different hidapi.dll is shadowing the one muxplex-deck bundles:\n"
270
+ f" resolved (what streamdeck will actually load): {resolved}\n"
271
+ f" bundled (what muxplex-deck vendors): {vendored}\n"
272
+ "Something earlier on %PATH% is winning the load race. Remove or\n"
273
+ "reorder it, then run muxplex-deck doctor again to confirm."
274
+ )
275
+
276
+
277
+ def _elgato_app_running() -> bool | None:
278
+ """Best-effort: is a process resembling the official Elgato app running?
279
+
280
+ `None` if it could not be determined (e.g. `tasklist` missing, timed
281
+ out, or any other failure) -- callers must not treat "could not check"
282
+ as "not running", the same never-guess discipline every other
283
+ fact-gatherer in this module follows.
284
+ """
285
+ import subprocess
286
+
287
+ try:
288
+ result = subprocess.run(
289
+ ["tasklist"], capture_output=True, text=True, timeout=5, check=False
290
+ )
291
+ except (OSError, subprocess.TimeoutExpired):
292
+ return None
293
+ if result.returncode != 0:
294
+ return None
295
+ return "streamdeck" in result.stdout.lower()
296
+
297
+
298
+ def _win_usbip_guidance(*, allow_usbipd_query: bool) -> Guidance | None:
299
+ """WIN-USBIP: the deck is currently bound to WSL, invisible to native Windows.
300
+
301
+ Reuses `wsl.find_usbipd()` / `wsl.list_devices()` as-is -- `usbipd.exe
302
+ list`'s output is the same text whether invoked from WSL or native
303
+ PowerShell. `wsl.attach()` is never called from this path; it remains
304
+ the one mutating function in the whole surface, WSL-only.
305
+ """
306
+ if not allow_usbipd_query:
307
+ return None
308
+ paths = wsl.find_usbipd()
309
+ if paths.windows is None:
310
+ return None
311
+ devices = wsl.list_devices(paths.windows, vendor_id=_ELGATO_VENDOR_ID)
312
+ if not devices:
313
+ return None
314
+ device = devices[0]
315
+ if device.state != "attached":
316
+ return None
317
+ return Guidance(status="warn", message=win_usbip_message(device), state="WIN-USBIP")
318
+
319
+
320
+ def _windows_guidance(*, allow_usbipd_query: bool = True) -> list[Guidance]:
321
+ """Native-Windows environment guidance. Returns [] on a healthy box.
322
+
323
+ Checked in priority order -- each state, once true, fully explains "no
324
+ device" on its own, and the remaining checks would either be moot (the
325
+ app holds the device -- nothing else matters) or misleading (once
326
+ WIN-USBIP explains why Windows can't see the device at all, checking
327
+ hidapi resolution is a distraction):
328
+
329
+ 1. WIN-ELGATO -- the #1 native-Windows failure and cheaply detectable;
330
+ say it specifically instead of a bare "no device found".
331
+ 2. WIN-USBIP -- the deck is handed to WSL.
332
+ 3. WIN-NOHIDAPI -- the vendored DLL itself is missing.
333
+ 4. WIN-DLL-SHADOW -- the vendored DLL exists but something else on
334
+ %PATH% won the load race (see `hidapi_win`'s module docstring for
335
+ the mechanism this closes).
336
+
337
+ `allow_usbipd_query=False` skips WIN-ELGATO and WIN-USBIP (both shell
338
+ out) -- the same escape hatch `explain_environment()` honors for
339
+ contexts that must not shell out.
340
+ """
341
+ if allow_usbipd_query and _elgato_app_running():
342
+ return [Guidance(status="warn", message=WIN_ELGATO_MESSAGE, state="WIN-ELGATO")]
343
+
344
+ usbip_guidance = _win_usbip_guidance(allow_usbipd_query=allow_usbipd_query)
345
+ if usbip_guidance is not None:
346
+ return [usbip_guidance]
347
+
348
+ from . import hidapi_win
349
+
350
+ dll_dir = hidapi_win.ensure_hidapi()
351
+ if dll_dir is None:
352
+ return [
353
+ Guidance(status="warn", message=WIN_NOHIDAPI_MESSAGE, state="WIN-NOHIDAPI")
354
+ ]
355
+
356
+ resolved = hidapi_win.resolved_library_path()
357
+ vendored = str(hidapi_win.vendored_dll_path())
358
+ # Case-insensitive: Windows paths are case-insensitive, and a bare
359
+ # `os.path.normcase` comparison would only behave that way when the
360
+ # process actually running is Windows (it's bound to ntpath vs
361
+ # posixpath at interpreter start, unaffected by monkeypatching
362
+ # `sys.platform` in tests) -- `.lower()` keeps this deterministic
363
+ # everywhere the check runs, including this repo's Linux-only CI.
364
+ if resolved is not None and resolved.lower() != vendored.lower():
365
+ return [
366
+ Guidance(
367
+ status="warn",
368
+ message=win_dll_shadow_message(resolved, vendored),
369
+ state="WIN-DLL-SHADOW",
370
+ )
371
+ ]
372
+
373
+ return []
374
+
375
+
233
376
  # ---------------------------------------------------------------------------
234
377
  # Public API
235
378
  # ---------------------------------------------------------------------------
@@ -251,7 +394,9 @@ def explain_environment(*, allow_usbipd_query: bool = True) -> list[Guidance]:
251
394
  info = wsl.detect()
252
395
 
253
396
  if not info.is_wsl:
254
- if sys.platform not in ("darwin", "win32") and not usbnode.udev_is_live():
397
+ if sys.platform == "win32":
398
+ return _windows_guidance(allow_usbipd_query=allow_usbipd_query)
399
+ if sys.platform != "darwin" and not usbnode.udev_is_live():
255
400
  guidances.append(
256
401
  Guidance(
257
402
  status="warn", message=u_dead_container_message(), state="U-DEAD"
@@ -33,6 +33,7 @@ import contextlib
33
33
  import json
34
34
  import logging
35
35
  import os
36
+ import sys
36
37
  import tempfile
37
38
  import time
38
39
  from pathlib import Path
@@ -45,6 +46,43 @@ SCHEMA_VERSION = 1
45
46
  _STATUS_DIR_NAME = "muxplex-deck"
46
47
  _STATUS_FILE_NAME = "status.json"
47
48
 
49
+ # Windows only: os.replace() raises PermissionError when the destination is
50
+ # open by another process -- and cli.status()/_wait_for_fresh_status() read
51
+ # this exact file while the sidecar is writing it. The blanket `except
52
+ # Exception` below already degrades a dropped write to a logged warning
53
+ # (never a crash), but a dropped write during the restart-wait window could
54
+ # manufacture exactly the false "has not published fresh status" alarm
55
+ # v0.5.3 was released to eliminate -- see WINDOWS_NATIVE_SPEC.md section
56
+ # 3.4. POSIX `rename()`/`replace()` has no such failure mode (an open file
57
+ # descriptor doesn't block a rename), so this retry is Windows-only and
58
+ # does not change behavior anywhere else.
59
+ _WIN_REPLACE_RETRY_ATTEMPTS = 3
60
+ _WIN_REPLACE_RETRY_DELAY_SECONDS = 0.05
61
+
62
+
63
+ def _replace_with_windows_retry(src: str, dst: Path) -> None:
64
+ """`os.replace(src, dst)`, retrying on Windows if the destination is locked.
65
+
66
+ Elsewhere this is just `os.replace(src, dst)` -- no retry, no delay,
67
+ byte-for-byte the previous behavior. On `win32`, retries up to
68
+ `_WIN_REPLACE_RETRY_ATTEMPTS` times, `_WIN_REPLACE_RETRY_DELAY_SECONDS`
69
+ apart, before giving up; the final attempt's exception (if it still
70
+ fails) propagates to the caller's existing `except Exception` handler,
71
+ which logs and swallows it exactly as before this retry existed.
72
+ """
73
+ if sys.platform != "win32":
74
+ os.replace(src, dst)
75
+ return
76
+
77
+ for attempt in range(_WIN_REPLACE_RETRY_ATTEMPTS):
78
+ try:
79
+ os.replace(src, dst)
80
+ return
81
+ except PermissionError:
82
+ if attempt == _WIN_REPLACE_RETRY_ATTEMPTS - 1:
83
+ raise
84
+ time.sleep(_WIN_REPLACE_RETRY_DELAY_SECONDS)
85
+
48
86
 
49
87
  def default_status_dir() -> Path:
50
88
  """`$XDG_STATE_HOME/muxplex-deck`, default `~/.local/state/muxplex-deck`."""
@@ -129,7 +167,7 @@ def write_status(status: dict[str, Any], path: Path | None = None) -> None:
129
167
  try:
130
168
  with os.fdopen(fd, "w", encoding="utf-8") as f:
131
169
  json.dump(status, f, indent=2)
132
- os.replace(tmp_path, target)
170
+ _replace_with_windows_retry(tmp_path, target)
133
171
  tmp_path = None
134
172
  finally:
135
173
  if tmp_path is not None:
File without changes