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.
- {muxplex_deck-0.5.3 → muxplex_deck-0.6.0}/PKG-INFO +1 -1
- {muxplex_deck-0.5.3 → muxplex_deck-0.6.0}/pyproject.toml +1 -1
- {muxplex_deck-0.5.3 → muxplex_deck-0.6.0}/src/deck_probe/main.py +6 -0
- muxplex_deck-0.6.0/src/muxplex_deck/_vendor/hidapi/LICENSE-bsd.txt +26 -0
- muxplex_deck-0.6.0/src/muxplex_deck/_vendor/hidapi/VERSION +31 -0
- muxplex_deck-0.6.0/src/muxplex_deck/_vendor/hidapi/x64/hidapi.dll +0 -0
- {muxplex_deck-0.5.3 → muxplex_deck-0.6.0}/src/muxplex_deck/cli.py +71 -5
- {muxplex_deck-0.5.3 → muxplex_deck-0.6.0}/src/muxplex_deck/device_real.py +13 -2
- muxplex_deck-0.6.0/src/muxplex_deck/hidapi_win.py +113 -0
- {muxplex_deck-0.5.3 → muxplex_deck-0.6.0}/src/muxplex_deck/hidhelp.py +146 -1
- {muxplex_deck-0.5.3 → muxplex_deck-0.6.0}/src/muxplex_deck/statusfile.py +39 -1
- {muxplex_deck-0.5.3 → muxplex_deck-0.6.0}/README.md +0 -0
- {muxplex_deck-0.5.3 → muxplex_deck-0.6.0}/src/deck_probe/__init__.py +0 -0
- {muxplex_deck-0.5.3 → muxplex_deck-0.6.0}/src/deck_probe/__main__.py +0 -0
- {muxplex_deck-0.5.3 → muxplex_deck-0.6.0}/src/deck_probe/capabilities.py +0 -0
- {muxplex_deck-0.5.3 → muxplex_deck-0.6.0}/src/deck_probe/events.py +0 -0
- {muxplex_deck-0.5.3 → muxplex_deck-0.6.0}/src/deck_probe/rendering.py +0 -0
- {muxplex_deck-0.5.3 → muxplex_deck-0.6.0}/src/muxplex_deck/__init__.py +0 -0
- {muxplex_deck-0.5.3 → muxplex_deck-0.6.0}/src/muxplex_deck/__main__.py +0 -0
- {muxplex_deck-0.5.3 → muxplex_deck-0.6.0}/src/muxplex_deck/attention.py +0 -0
- {muxplex_deck-0.5.3 → muxplex_deck-0.6.0}/src/muxplex_deck/config.py +0 -0
- {muxplex_deck-0.5.3 → muxplex_deck-0.6.0}/src/muxplex_deck/device.py +0 -0
- {muxplex_deck-0.5.3 → muxplex_deck-0.6.0}/src/muxplex_deck/emulator.py +0 -0
- {muxplex_deck-0.5.3 → muxplex_deck-0.6.0}/src/muxplex_deck/focus.py +0 -0
- {muxplex_deck-0.5.3 → muxplex_deck-0.6.0}/src/muxplex_deck/init_wizard.py +0 -0
- {muxplex_deck-0.5.3 → muxplex_deck-0.6.0}/src/muxplex_deck/interaction.py +0 -0
- {muxplex_deck-0.5.3 → muxplex_deck-0.6.0}/src/muxplex_deck/layout.py +0 -0
- {muxplex_deck-0.5.3 → muxplex_deck-0.6.0}/src/muxplex_deck/main.py +0 -0
- {muxplex_deck-0.5.3 → muxplex_deck-0.6.0}/src/muxplex_deck/rendering.py +0 -0
- {muxplex_deck-0.5.3 → muxplex_deck-0.6.0}/src/muxplex_deck/service.py +0 -0
- {muxplex_deck-0.5.3 → muxplex_deck-0.6.0}/src/muxplex_deck/usbnode.py +0 -0
- {muxplex_deck-0.5.3 → muxplex_deck-0.6.0}/src/muxplex_deck/views.py +0 -0
- {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.
|
|
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>
|
|
@@ -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).
|
|
Binary file
|
|
@@ -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:
|
|
38
|
-
"
|
|
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
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|