muxplex-deck 0.5.2__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.2 → muxplex_deck-0.6.0}/PKG-INFO +1 -1
  2. {muxplex_deck-0.5.2 → muxplex_deck-0.6.0}/pyproject.toml +1 -1
  3. {muxplex_deck-0.5.2 → 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.2 → muxplex_deck-0.6.0}/src/muxplex_deck/cli.py +165 -7
  8. {muxplex_deck-0.5.2 → 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.2 → muxplex_deck-0.6.0}/src/muxplex_deck/hidhelp.py +146 -1
  11. {muxplex_deck-0.5.2 → muxplex_deck-0.6.0}/src/muxplex_deck/init_wizard.py +46 -9
  12. {muxplex_deck-0.5.2 → muxplex_deck-0.6.0}/src/muxplex_deck/service.py +135 -5
  13. {muxplex_deck-0.5.2 → muxplex_deck-0.6.0}/src/muxplex_deck/statusfile.py +39 -1
  14. {muxplex_deck-0.5.2 → muxplex_deck-0.6.0}/README.md +0 -0
  15. {muxplex_deck-0.5.2 → muxplex_deck-0.6.0}/src/deck_probe/__init__.py +0 -0
  16. {muxplex_deck-0.5.2 → muxplex_deck-0.6.0}/src/deck_probe/__main__.py +0 -0
  17. {muxplex_deck-0.5.2 → muxplex_deck-0.6.0}/src/deck_probe/capabilities.py +0 -0
  18. {muxplex_deck-0.5.2 → muxplex_deck-0.6.0}/src/deck_probe/events.py +0 -0
  19. {muxplex_deck-0.5.2 → muxplex_deck-0.6.0}/src/deck_probe/rendering.py +0 -0
  20. {muxplex_deck-0.5.2 → muxplex_deck-0.6.0}/src/muxplex_deck/__init__.py +0 -0
  21. {muxplex_deck-0.5.2 → muxplex_deck-0.6.0}/src/muxplex_deck/__main__.py +0 -0
  22. {muxplex_deck-0.5.2 → muxplex_deck-0.6.0}/src/muxplex_deck/attention.py +0 -0
  23. {muxplex_deck-0.5.2 → muxplex_deck-0.6.0}/src/muxplex_deck/config.py +0 -0
  24. {muxplex_deck-0.5.2 → muxplex_deck-0.6.0}/src/muxplex_deck/device.py +0 -0
  25. {muxplex_deck-0.5.2 → muxplex_deck-0.6.0}/src/muxplex_deck/emulator.py +0 -0
  26. {muxplex_deck-0.5.2 → muxplex_deck-0.6.0}/src/muxplex_deck/focus.py +0 -0
  27. {muxplex_deck-0.5.2 → muxplex_deck-0.6.0}/src/muxplex_deck/interaction.py +0 -0
  28. {muxplex_deck-0.5.2 → muxplex_deck-0.6.0}/src/muxplex_deck/layout.py +0 -0
  29. {muxplex_deck-0.5.2 → muxplex_deck-0.6.0}/src/muxplex_deck/main.py +0 -0
  30. {muxplex_deck-0.5.2 → muxplex_deck-0.6.0}/src/muxplex_deck/rendering.py +0 -0
  31. {muxplex_deck-0.5.2 → muxplex_deck-0.6.0}/src/muxplex_deck/usbnode.py +0 -0
  32. {muxplex_deck-0.5.2 → muxplex_deck-0.6.0}/src/muxplex_deck/views.py +0 -0
  33. {muxplex_deck-0.5.2 → 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.2
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.2"
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
 
@@ -593,6 +658,60 @@ def check_server_reachable(server_url: str, ca_file: Path | None) -> tuple[str,
593
658
  return "warn", f"Server check failed: {exc}"
594
659
 
595
660
 
661
+ def check_federation_key_auth(
662
+ server_url: str, federation_key: str, *, verify: bool | str = True
663
+ ) -> tuple[str, str]:
664
+ """Issue ONE authenticated request to confirm `federation_key` is actually accepted.
665
+
666
+ `/api/instance-info` (what `check_server_reachable`/`fetch_instance_info`
667
+ call) is unauthenticated by design on the server -- it proves TLS and
668
+ reachability, but nothing about a credential. That gap is exactly what
669
+ let `init` once print a green "Server reachable" check, write config,
670
+ and offer to install the service for a federation key that was simply
671
+ wrong -- the user only found out once the running service couldn't
672
+ authenticate (see AGENTS.md).
673
+
674
+ This hits `GET /api/sessions` instead: authenticated, read-only, the
675
+ same lightweight endpoint the sidecar's own active loop already polls
676
+ (see `main.py`), with the key sent as `Authorization: Bearer` -- the
677
+ exact scheme the muxplex server's `auth.py` checks. Returns one of:
678
+
679
+ ("ok", ...) -- HTTP 200: the key is accepted.
680
+ ("fail", ...) -- HTTP 401: the key is definitively rejected.
681
+ Callers must not save a key that gets this back.
682
+ ("warn", ...) -- anything else (timeout, connection error,
683
+ unexpected status): could not verify one way or
684
+ the other. This is NOT evidence the key is bad --
685
+ callers must not print a false success, but also
686
+ must not treat an unrelated network hiccup as a
687
+ rejected key.
688
+ """
689
+ import httpx
690
+
691
+ url = f"{server_url.rstrip('/')}/api/sessions"
692
+ headers = {"Authorization": f"Bearer {federation_key}"}
693
+ try:
694
+ with httpx.Client(verify=verify, timeout=5.0) as client:
695
+ resp = client.get(url, headers=headers)
696
+ except httpx.TimeoutException as exc:
697
+ return (
698
+ "warn",
699
+ f"Could not verify federation key -- server check timed out: {exc}",
700
+ )
701
+ except httpx.ConnectError as exc:
702
+ return "warn", f"Could not verify federation key -- server unreachable: {exc}"
703
+ except Exception as exc: # noqa: BLE001 -- verification must degrade to warn, never raise
704
+ return "warn", f"Could not verify federation key: {exc}"
705
+
706
+ if resp.status_code == 200:
707
+ return "ok", "Federation key accepted by server"
708
+ if resp.status_code == 401:
709
+ return "fail", "Federation key rejected by server (401 Unauthorized)"
710
+ return "warn", (
711
+ f"Could not verify federation key -- unexpected response HTTP {resp.status_code}"
712
+ )
713
+
714
+
596
715
  def check_service_status() -> tuple[str, str]:
597
716
  """Service-state check: not installed / installed but not running / running.
598
717
 
@@ -629,6 +748,13 @@ _CHECK_MARKS: dict[str, str] = {
629
748
  "ok": "\033[32m\u2713\033[0m",
630
749
  "fail": "\033[31m\u2717\033[0m",
631
750
  "warn": "\033[33m!\033[0m",
751
+ # Distinct from "warn": "warn" means a check RAN and found something to
752
+ # flag; "unknown" means the check could not be run at all yet (e.g. the
753
+ # currently-running process hasn't published a status write of its own
754
+ # yet). Conflating the two is what let a dying process's stale-but-
755
+ # recent status snapshot get reported as definite failures right after
756
+ # `service restart` -- see AGENTS.md's restart-race incident.
757
+ "unknown": "\033[36m?\033[0m",
632
758
  }
633
759
 
634
760
 
@@ -668,6 +794,11 @@ def doctor(config_path: str | None = None) -> int:
668
794
  ca_file = config_mod._expand(raw["ca_file"])
669
795
  checks.append(check_ca_file(ca_file))
670
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
+
671
802
  # Environment guidance (WSL/usbipd/udev-liveness) BEFORE the device
672
803
  # checks -- it explains why the next line is about to warn. Returns []
673
804
  # on a healthy platform (macOS, or native Linux with udev running), so
@@ -789,7 +920,7 @@ def status(config_path: str | None = None, *, as_json: bool = False) -> int:
789
920
  fall back to a direct probe -- this keeps `status` useful even before
790
921
  the service has ever been installed.
791
922
  """
792
- from .service import service_is_active
923
+ from .service import service_is_active, service_main_pid
793
924
  from .statusfile import read_status
794
925
 
795
926
  running = service_is_active()
@@ -825,13 +956,44 @@ def status(config_path: str | None = None, *, as_json: bool = False) -> int:
825
956
 
826
957
  if data is None:
827
958
  print_check(
828
- "warn",
959
+ "unknown",
829
960
  "No status file found even though the service is running -- it "
830
961
  "may have just started. Try: muxplex-deck service logs",
831
962
  )
832
963
  print()
833
964
  return 0
834
965
 
966
+ # Is this snapshot from the process running RIGHT NOW, or from a
967
+ # PREVIOUS incarnation? Age alone can't tell: a process that's about to
968
+ # be replaced (e.g. by `service restart`) can write its LAST status
969
+ # moments before exiting, so it still looks "fresh" by age even though
970
+ # it no longer reflects reality -- exactly the restart-race incident in
971
+ # AGENTS.md (2 false failures reported from a dying process's stale-but-
972
+ # recent write). Comparing the recorded pid to the service manager's own
973
+ # live MainPID is the reliable signal; fall back to the age check only
974
+ # when the live pid can't be determined at all (unsupported platform,
975
+ # command failure).
976
+ current_pid = service_main_pid()
977
+ recorded_pid = data.get("pid")
978
+ if current_pid is not None:
979
+ is_current = recorded_pid == current_pid
980
+ else:
981
+ is_current = (
982
+ time.time() - data.get("updated_at", 0)
983
+ ) <= _STATUS_STALE_THRESHOLD_SECONDS
984
+
985
+ if not is_current:
986
+ print_check(
987
+ "unknown",
988
+ f"Status not yet available for the running process (pid "
989
+ f"{current_pid if current_pid is not None else '?'}) -- the "
990
+ f"published status is from a previous run (pid {recorded_pid}). "
991
+ "This is expected right after (re)starting; try again in a "
992
+ "moment, or: muxplex-deck service logs",
993
+ )
994
+ print()
995
+ return 0
996
+
835
997
  age = time.time() - data.get("updated_at", 0)
836
998
  if age > _STATUS_STALE_THRESHOLD_SECONDS:
837
999
  print_check(
@@ -886,8 +1048,6 @@ def _find_uv() -> str | None:
886
1048
 
887
1049
 
888
1050
  def _is_executable(path: str) -> bool:
889
- import os
890
-
891
1051
  return os.access(path, os.X_OK)
892
1052
 
893
1053
 
@@ -977,8 +1137,6 @@ def update(*, force: bool = False) -> None:
977
1137
  if was_active:
978
1138
  print(" Stopping service...")
979
1139
  if sys.platform == "darwin":
980
- import os
981
-
982
1140
  uid = os.getuid()
983
1141
  subprocess.run(
984
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"
@@ -207,10 +207,24 @@ def _resolve_ca(
207
207
  def _resolve_federation_key(
208
208
  key_file_path: Path,
209
209
  *,
210
+ server_url: str,
211
+ ca_file: str | None,
210
212
  non_interactive: bool,
211
213
  input_func: Callable[[str], str],
212
214
  getpass_func: Callable[[str], str],
213
215
  ) -> None:
216
+ """Resolve, VALIDATE, and write the federation key.
217
+
218
+ A pasted/loaded key is never written on faith. `/api/instance-info`
219
+ (already used above to verify the server) is unauthenticated and proves
220
+ nothing about a credential -- this is exactly the gap that let `init`
221
+ once print a green "Server reachable" check, write config, and offer to
222
+ install the service for a federation key that was simply wrong; the
223
+ user only found out once the running service couldn't authenticate
224
+ (see AGENTS.md). `cli.check_federation_key_auth` issues one real
225
+ authenticated request and this loops until it's accepted, or the user
226
+ gives up (Ctrl-C/EOF, handled by `run_init`'s caller).
227
+ """
214
228
  if key_file_path.exists() and key_file_path.stat().st_size > 0:
215
229
  if non_interactive:
216
230
  print(f" Federation key: keeping existing {key_file_path}")
@@ -237,17 +251,38 @@ def _resolve_federation_key(
237
251
  "read ~/.config/muxplex/federation_key there. SSH fallback:"
238
252
  )
239
253
  print(f" scp <your-server>:.config/muxplex/federation_key {key_file_path}")
240
- pasted = getpass_func("Federation key (or path): ")
241
- value = pasted.strip()
242
- candidate_path = Path(value).expanduser() if value else None
243
254
 
244
- if candidate_path is not None and candidate_path.is_file():
245
- key_value = candidate_path.read_text(encoding="utf-8").strip()
246
- else:
247
- key_value = value
255
+ verify: bool | str = ca_file if ca_file else True
256
+ key_value = ""
257
+ while True:
258
+ pasted = getpass_func("Federation key (or path): ")
259
+ value = pasted.strip()
260
+ candidate_path = Path(value).expanduser() if value else None
261
+
262
+ if candidate_path is not None and candidate_path.is_file():
263
+ key_value = candidate_path.read_text(encoding="utf-8").strip()
264
+ else:
265
+ key_value = value
248
266
 
249
- if not key_value:
250
- raise _InitError("no federation key provided")
267
+ if not key_value:
268
+ raise _InitError("no federation key provided")
269
+
270
+ status, message = cli_mod.check_federation_key_auth(
271
+ server_url, key_value, verify=verify
272
+ )
273
+ if status == "ok":
274
+ cli_mod.print_check("ok", "Federation key: verified against server")
275
+ break
276
+ if status == "fail":
277
+ cli_mod.print_check("fail", message)
278
+ print(" Try again -- paste the correct key, or a path to it.")
279
+ continue
280
+ # status == "warn": could not verify one way or the other (network
281
+ # hiccup, timeout) -- not evidence the key itself is wrong, so
282
+ # don't loop forever on something re-pasting can't fix. Still,
283
+ # never claim a check that didn't actually run succeeded.
284
+ cli_mod.print_check("warn", message)
285
+ break
251
286
 
252
287
  key_file_path.parent.mkdir(parents=True, exist_ok=True)
253
288
  key_file_path.parent.chmod(0o700)
@@ -311,6 +346,8 @@ def _run_init_impl(
311
346
  key_file_raw = raw.get("key_file") or config_mod.DEFAULT_KEY_FILE
312
347
  _resolve_federation_key(
313
348
  config_mod._expand(key_file_raw),
349
+ server_url=server_url,
350
+ ca_file=ca_file_value,
314
351
  non_interactive=non_interactive,
315
352
  input_func=input_func,
316
353
  getpass_func=getpass_func,
@@ -72,6 +72,20 @@ _LAUNCHD_ALREADY_LOADED_EXIT = 5
72
72
  _LAUNCHD_BOOTOUT_POLL_INTERVAL_SECONDS = 0.2
73
73
  _LAUNCHD_BOOTOUT_TIMEOUT_SECONDS = 5.0
74
74
 
75
+ # `systemctl restart` / `launchctl bootstrap` both return once the new
76
+ # process has been LAUNCHED, not once it has done anything -- the new
77
+ # sidecar needs at least one poll cycle to open the device (or notice it
78
+ # can't) and publish its first status write via `statusfile.StatusReporter`.
79
+ # Until then, `status.json` still holds the PREVIOUS process's last
80
+ # snapshot, which `muxplex-deck status` would otherwise present as current,
81
+ # healthy truth (the restart-race incident in AGENTS.md: 2 false failures
82
+ # read from a dying process's stale-but-recent write, moments before it
83
+ # exited). `restart` polls for a status write from the NEW process (by pid,
84
+ # via `service_main_pid()`) before reporting success, bounded so a genuinely
85
+ # slow-starting sidecar can't hang the command forever.
86
+ _RESTART_STATUS_POLL_INTERVAL_SECONDS = 0.2
87
+ _RESTART_STATUS_TIMEOUT_SECONDS = 5.0
88
+
75
89
  # Elgato Stream Deck USB vendor id -- used to detect an existing udev rule.
76
90
  _ELGATO_VENDOR_ID = "0fd9"
77
91
  _UDEV_RULE_DIRS: tuple[Path, ...] = (
@@ -309,6 +323,70 @@ def service_is_active() -> bool:
309
323
  return result.returncode == 0
310
324
 
311
325
 
326
+ def service_main_pid() -> int | None:
327
+ """Best-effort PID of the currently-running muxplex-deck service process.
328
+
329
+ Never raises -- matches `service_is_active()`'s contract: any failure
330
+ (service manager missing, service not running, unexpected/unparseable
331
+ output) reads as "cannot determine", which callers must treat as "don't
332
+ know", never as a false positive or negative on its own.
333
+
334
+ This is what lets `status()` (and `_wait_for_fresh_status()` below) tell
335
+ whether a published `status.json` (`statusfile.build_status()`'s own
336
+ `pid` field) was written by the process running RIGHT NOW under the
337
+ service, or by a PREVIOUS incarnation whose last write can look
338
+ deceptively fresh by age alone -- see the restart-race incident in
339
+ AGENTS.md: the old process's last write happened moments before it
340
+ exited, so an age-only staleness check saw it as "recent" and reported
341
+ it as current truth. Comparing pids is the only way to tell those apart.
342
+ """
343
+ if _is_darwin():
344
+ uid = os.getuid()
345
+ try:
346
+ result = subprocess.run(
347
+ ["launchctl", "print", f"gui/{uid}/{_LAUNCHD_LABEL}"],
348
+ capture_output=True,
349
+ text=True,
350
+ check=False,
351
+ )
352
+ except FileNotFoundError:
353
+ return None
354
+ if result.returncode != 0:
355
+ return None
356
+ for line in result.stdout.splitlines():
357
+ stripped = line.strip()
358
+ if stripped.startswith("pid = "):
359
+ try:
360
+ return int(stripped[len("pid = ") :].strip())
361
+ except ValueError:
362
+ return None
363
+ return None
364
+
365
+ try:
366
+ result = subprocess.run(
367
+ [
368
+ "systemctl",
369
+ "--user",
370
+ "show",
371
+ "muxplex-deck",
372
+ "--property=MainPID",
373
+ "--value",
374
+ ],
375
+ capture_output=True,
376
+ text=True,
377
+ check=False,
378
+ )
379
+ except FileNotFoundError:
380
+ return None
381
+ if result.returncode != 0:
382
+ return None
383
+ try:
384
+ pid = int(result.stdout.strip())
385
+ except ValueError:
386
+ return None
387
+ return pid if pid > 0 else None
388
+
389
+
312
390
  def service_is_installed() -> bool:
313
391
  """Is the muxplex-deck unit/plist file present on disk?
314
392
 
@@ -502,6 +580,56 @@ def _systemd_stop() -> None:
502
580
  subprocess.run(["systemctl", "--user", "stop", "muxplex-deck"], check=False)
503
581
 
504
582
 
583
+ def _wait_for_fresh_status(timeout: float | None = None) -> bool:
584
+ """Poll until `status.json`'s recorded pid matches the service's live
585
+ MainPID, or `timeout` elapses.
586
+
587
+ See `_RESTART_STATUS_TIMEOUT_SECONDS`'s docstring for why this exists:
588
+ the process a restart just launched needs at least one poll cycle
589
+ before it publishes its own status, and comparing pids (not wall-clock
590
+ age) is the only reliable way to tell "this snapshot belongs to the
591
+ process running right now" from "this is the previous process's last
592
+ write" -- the exact restart-race incident in AGENTS.md.
593
+
594
+ Returns True once a matching status is observed; False if `timeout`
595
+ elapses first. Never raises -- `service_main_pid()` and
596
+ `statusfile.read_status()` are both best-effort/never-raise themselves.
597
+ """
598
+ from . import statusfile
599
+
600
+ if timeout is None:
601
+ timeout = _RESTART_STATUS_TIMEOUT_SECONDS
602
+ deadline = time.monotonic() + timeout
603
+ while True:
604
+ current_pid = service_main_pid()
605
+ if current_pid is not None:
606
+ data = statusfile.read_status()
607
+ if data is not None and data.get("pid") == current_pid:
608
+ return True
609
+ if time.monotonic() >= deadline:
610
+ return False
611
+ time.sleep(_RESTART_STATUS_POLL_INTERVAL_SECONDS)
612
+
613
+
614
+ def _report_restart_result(success_message: str) -> None:
615
+ """Print the final restart line: success only once fresh status is
616
+ actually observed, otherwise an honest "still waiting" warning.
617
+
618
+ Shared by systemd and launchd so both platforms make the same promise:
619
+ never print a success line for a step (the new process being up and
620
+ reporting) that was not actually verified -- see AGENTS.md's restart-
621
+ race incident, which this directly closes.
622
+ """
623
+ if _wait_for_fresh_status():
624
+ _step_ok(success_message)
625
+ else:
626
+ _step_warn(
627
+ "Restart command sent, but the service has not published fresh "
628
+ f"status within {_RESTART_STATUS_TIMEOUT_SECONDS:.0f}s -- it may "
629
+ "still be starting. Check: muxplex-deck status"
630
+ )
631
+
632
+
505
633
  def _systemd_restart() -> None:
506
634
  # `systemctl restart` is a single atomic transaction (unlike launchd's
507
635
  # separate bootout + bootstrap), so it needs no unload-race handling --
@@ -513,12 +641,12 @@ def _systemd_restart() -> None:
513
641
  text=True,
514
642
  check=False,
515
643
  )
516
- if result.returncode == 0:
517
- _step_ok("Restarted the service")
518
- else:
644
+ if result.returncode != 0:
519
645
  _report_systemctl_failure("restart", result)
520
646
  sys.exit(1)
521
647
 
648
+ _report_restart_result("Restarted the service")
649
+
522
650
 
523
651
  def _systemd_status() -> None:
524
652
  subprocess.run(
@@ -704,13 +832,15 @@ def _launchd_restart() -> None:
704
832
 
705
833
  result = _launchd_bootstrap()
706
834
  if result.returncode == 0:
707
- _step_ok("Restarted the service (launchctl bootstrap)")
835
+ success_message = "Restarted the service (launchctl bootstrap)"
708
836
  elif result.returncode == _LAUNCHD_ALREADY_LOADED_EXIT:
709
- _step_ok("Service is running")
837
+ success_message = "Service is running"
710
838
  else:
711
839
  _report_launchctl_failure("bootstrap", result)
712
840
  sys.exit(1)
713
841
 
842
+ _report_restart_result(success_message)
843
+
714
844
 
715
845
  def _launchd_status() -> None:
716
846
  uid = os.getuid()
@@ -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