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.
- {muxplex_deck-0.5.2 → muxplex_deck-0.6.0}/PKG-INFO +1 -1
- {muxplex_deck-0.5.2 → muxplex_deck-0.6.0}/pyproject.toml +1 -1
- {muxplex_deck-0.5.2 → 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.2 → muxplex_deck-0.6.0}/src/muxplex_deck/cli.py +165 -7
- {muxplex_deck-0.5.2 → 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.2 → muxplex_deck-0.6.0}/src/muxplex_deck/hidhelp.py +146 -1
- {muxplex_deck-0.5.2 → muxplex_deck-0.6.0}/src/muxplex_deck/init_wizard.py +46 -9
- {muxplex_deck-0.5.2 → muxplex_deck-0.6.0}/src/muxplex_deck/service.py +135 -5
- {muxplex_deck-0.5.2 → muxplex_deck-0.6.0}/src/muxplex_deck/statusfile.py +39 -1
- {muxplex_deck-0.5.2 → muxplex_deck-0.6.0}/README.md +0 -0
- {muxplex_deck-0.5.2 → muxplex_deck-0.6.0}/src/deck_probe/__init__.py +0 -0
- {muxplex_deck-0.5.2 → muxplex_deck-0.6.0}/src/deck_probe/__main__.py +0 -0
- {muxplex_deck-0.5.2 → muxplex_deck-0.6.0}/src/deck_probe/capabilities.py +0 -0
- {muxplex_deck-0.5.2 → muxplex_deck-0.6.0}/src/deck_probe/events.py +0 -0
- {muxplex_deck-0.5.2 → muxplex_deck-0.6.0}/src/deck_probe/rendering.py +0 -0
- {muxplex_deck-0.5.2 → muxplex_deck-0.6.0}/src/muxplex_deck/__init__.py +0 -0
- {muxplex_deck-0.5.2 → muxplex_deck-0.6.0}/src/muxplex_deck/__main__.py +0 -0
- {muxplex_deck-0.5.2 → muxplex_deck-0.6.0}/src/muxplex_deck/attention.py +0 -0
- {muxplex_deck-0.5.2 → muxplex_deck-0.6.0}/src/muxplex_deck/config.py +0 -0
- {muxplex_deck-0.5.2 → muxplex_deck-0.6.0}/src/muxplex_deck/device.py +0 -0
- {muxplex_deck-0.5.2 → muxplex_deck-0.6.0}/src/muxplex_deck/emulator.py +0 -0
- {muxplex_deck-0.5.2 → muxplex_deck-0.6.0}/src/muxplex_deck/focus.py +0 -0
- {muxplex_deck-0.5.2 → muxplex_deck-0.6.0}/src/muxplex_deck/interaction.py +0 -0
- {muxplex_deck-0.5.2 → muxplex_deck-0.6.0}/src/muxplex_deck/layout.py +0 -0
- {muxplex_deck-0.5.2 → muxplex_deck-0.6.0}/src/muxplex_deck/main.py +0 -0
- {muxplex_deck-0.5.2 → muxplex_deck-0.6.0}/src/muxplex_deck/rendering.py +0 -0
- {muxplex_deck-0.5.2 → muxplex_deck-0.6.0}/src/muxplex_deck/usbnode.py +0 -0
- {muxplex_deck-0.5.2 → muxplex_deck-0.6.0}/src/muxplex_deck/views.py +0 -0
- {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.
|
|
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
|
|
|
@@ -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
|
-
"
|
|
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:
|
|
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"
|
|
@@ -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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
250
|
-
|
|
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
|
|
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
|
-
|
|
835
|
+
success_message = "Restarted the service (launchctl bootstrap)"
|
|
708
836
|
elif result.returncode == _LAUNCHD_ALREADY_LOADED_EXIT:
|
|
709
|
-
|
|
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
|
-
|
|
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
|