muxplex-deck 0.4.0__tar.gz → 0.4.1__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 (26) hide show
  1. {muxplex_deck-0.4.0 → muxplex_deck-0.4.1}/PKG-INFO +1 -1
  2. {muxplex_deck-0.4.0 → muxplex_deck-0.4.1}/pyproject.toml +1 -1
  3. {muxplex_deck-0.4.0 → muxplex_deck-0.4.1}/src/muxplex_deck/cli.py +73 -12
  4. {muxplex_deck-0.4.0 → muxplex_deck-0.4.1}/src/muxplex_deck/main.py +2 -1
  5. {muxplex_deck-0.4.0 → muxplex_deck-0.4.1}/src/muxplex_deck/service.py +172 -12
  6. {muxplex_deck-0.4.0 → muxplex_deck-0.4.1}/README.md +0 -0
  7. {muxplex_deck-0.4.0 → muxplex_deck-0.4.1}/src/deck_probe/__init__.py +0 -0
  8. {muxplex_deck-0.4.0 → muxplex_deck-0.4.1}/src/deck_probe/__main__.py +0 -0
  9. {muxplex_deck-0.4.0 → muxplex_deck-0.4.1}/src/deck_probe/capabilities.py +0 -0
  10. {muxplex_deck-0.4.0 → muxplex_deck-0.4.1}/src/deck_probe/events.py +0 -0
  11. {muxplex_deck-0.4.0 → muxplex_deck-0.4.1}/src/deck_probe/main.py +0 -0
  12. {muxplex_deck-0.4.0 → muxplex_deck-0.4.1}/src/deck_probe/rendering.py +0 -0
  13. {muxplex_deck-0.4.0 → muxplex_deck-0.4.1}/src/muxplex_deck/__init__.py +0 -0
  14. {muxplex_deck-0.4.0 → muxplex_deck-0.4.1}/src/muxplex_deck/__main__.py +0 -0
  15. {muxplex_deck-0.4.0 → muxplex_deck-0.4.1}/src/muxplex_deck/attention.py +0 -0
  16. {muxplex_deck-0.4.0 → muxplex_deck-0.4.1}/src/muxplex_deck/config.py +0 -0
  17. {muxplex_deck-0.4.0 → muxplex_deck-0.4.1}/src/muxplex_deck/device.py +0 -0
  18. {muxplex_deck-0.4.0 → muxplex_deck-0.4.1}/src/muxplex_deck/device_real.py +0 -0
  19. {muxplex_deck-0.4.0 → muxplex_deck-0.4.1}/src/muxplex_deck/emulator.py +0 -0
  20. {muxplex_deck-0.4.0 → muxplex_deck-0.4.1}/src/muxplex_deck/focus.py +0 -0
  21. {muxplex_deck-0.4.0 → muxplex_deck-0.4.1}/src/muxplex_deck/init_wizard.py +0 -0
  22. {muxplex_deck-0.4.0 → muxplex_deck-0.4.1}/src/muxplex_deck/interaction.py +0 -0
  23. {muxplex_deck-0.4.0 → muxplex_deck-0.4.1}/src/muxplex_deck/layout.py +0 -0
  24. {muxplex_deck-0.4.0 → muxplex_deck-0.4.1}/src/muxplex_deck/rendering.py +1 -1
  25. {muxplex_deck-0.4.0 → muxplex_deck-0.4.1}/src/muxplex_deck/statusfile.py +0 -0
  26. {muxplex_deck-0.4.0 → muxplex_deck-0.4.1}/src/muxplex_deck/views.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: muxplex-deck
3
- Version: 0.4.0
3
+ Version: 0.4.1
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.4.0"
3
+ version = "0.4.1"
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 = [
@@ -251,11 +251,14 @@ def _get_install_info() -> dict:
251
251
  return info
252
252
 
253
253
 
254
+ _PYPI_PROJECT_URL = "https://pypi.org/pypi/muxplex-deck/json"
255
+
256
+
254
257
  def _check_for_update(info: dict) -> tuple[bool, str]:
255
258
  """Check if an update is available.
256
259
 
257
- muxplex-deck is git-only (no PyPI release) -- only the git comparison
258
- path applies. Editable installs are never flagged.
260
+ muxplex-deck 0.4.0+ is published to PyPI, so both the git and pypi
261
+ comparison paths apply now. Editable installs are never flagged.
259
262
  """
260
263
  if info["source"] == "editable":
261
264
  return False, "editable install -- manage updates manually"
@@ -283,6 +286,24 @@ def _check_for_update(info: dict) -> tuple[bool, str]:
283
286
  except Exception: # noqa: BLE001 -- any git/network failure means "upgrade to be safe"
284
287
  return True, "check failed -- upgrading to be safe"
285
288
 
289
+ if info["source"] == "pypi":
290
+ try:
291
+ import httpx
292
+
293
+ response = httpx.get(
294
+ _PYPI_PROJECT_URL,
295
+ headers={"Accept": "application/json"},
296
+ timeout=10,
297
+ )
298
+ response.raise_for_status()
299
+ latest = response.json()["info"]["version"]
300
+ current = info["version"]
301
+ if latest == current:
302
+ return False, f"up to date (v{current})"
303
+ return True, f"update available (v{current} -> v{latest})"
304
+ except Exception: # noqa: BLE001 -- any network/parse failure means "upgrade to be safe"
305
+ return True, "could not check PyPI -- upgrading to be safe"
306
+
286
307
  return True, "unknown install source -- could not check"
287
308
 
288
309
 
@@ -855,19 +876,54 @@ def _service_is_active() -> bool:
855
876
  return service_is_active()
856
877
 
857
878
 
858
- def update() -> None:
879
+ def update(*, force: bool = False) -> None:
859
880
  """Update muxplex-deck to the latest version and restart the service (if installed).
860
881
 
861
- Simplified relative to muxplex's own `upgrade()`: muxplex-deck has no
862
- PyPI release, so there is no "already up to date, use --force" version
863
- gate -- it always reinstalls from git HEAD (matching `uv tool install
864
- --force`'s own semantics). Reporting style (plain ` ERROR: ...` on
865
- stderr equivalent) matches muxplex's.
882
+ Respects how the tool was installed (see `_get_install_info`), mirroring
883
+ muxplex's own `upgrade()`: a `pypi` install upgrades from PyPI (package
884
+ name, so `uv tool install --force muxplex-deck` / `pip install --upgrade
885
+ muxplex-deck`); a `git` (or `unknown`) install keeps reinstalling from
886
+ git HEAD via `_REPO_URL`, exactly as before -- this honors an explicit
887
+ git install rather than migrating it. An `editable` install is left
888
+ alone entirely (dev checkout -- manage it via git yourself).
889
+
890
+ Now that a real PyPI release exists, the "already up to date" version
891
+ gate this module previously lacked (see AGENTS.md history) is real:
892
+ unless `force=True`, an install already at the latest version/commit
893
+ is reported and left untouched rather than reinstalled and restarted
894
+ for no code change. Reporting style (plain ` ERROR: ...` on stderr
895
+ equivalent) matches muxplex's.
866
896
  """
867
897
  from .service import service_install
868
898
 
869
899
  print("\nmuxplex-deck update\n")
870
900
 
901
+ info = _get_install_info()
902
+ commit_suffix = f" @ {info['commit'][:8]}" if info["commit"] else ""
903
+ print(
904
+ f" Installed: muxplex-deck {info['version']} (via {info['source']}{commit_suffix})"
905
+ )
906
+
907
+ if info["source"] == "editable":
908
+ print(
909
+ "\n Editable install detected -- manage updates via git yourself (no action taken).\n"
910
+ )
911
+ return
912
+
913
+ if force:
914
+ print(" Status: --force specified -- skipping version check")
915
+ else:
916
+ update_available, message = _check_for_update(info)
917
+ print(f" Status: {message}")
918
+ if not update_available:
919
+ print(
920
+ "\n Already up to date."
921
+ " Use 'muxplex-deck update --force' to reinstall anyway.\n"
922
+ )
923
+ return
924
+
925
+ install_target = "muxplex-deck" if info["source"] == "pypi" else f"git+{_REPO_URL}"
926
+
871
927
  was_active = _service_is_active()
872
928
  if was_active:
873
929
  print(" Stopping service...")
@@ -896,7 +952,7 @@ def update() -> None:
896
952
  uv_path = _find_uv()
897
953
  if uv_path:
898
954
  result = subprocess.run(
899
- [uv_path, "tool", "install", "--force", f"git+{_REPO_URL}"],
955
+ [uv_path, "tool", "install", "--force", install_target],
900
956
  capture_output=True,
901
957
  text=True,
902
958
  check=False,
@@ -910,7 +966,7 @@ def update() -> None:
910
966
  pip_path = _find_pip()
911
967
  if pip_path:
912
968
  result = subprocess.run(
913
- [pip_path, "install", "--upgrade", f"git+{_REPO_URL}"],
969
+ [pip_path, "install", "--upgrade", install_target],
914
970
  capture_output=True,
915
971
  text=True,
916
972
  check=False,
@@ -989,11 +1045,16 @@ def main() -> None:
989
1045
  "--json", action="store_true", help="Emit raw status as JSON"
990
1046
  )
991
1047
 
992
- sub.add_parser(
1048
+ update_parser = sub.add_parser(
993
1049
  "update",
994
1050
  aliases=["upgrade"],
995
1051
  help="Update muxplex-deck to the latest version and restart the service",
996
1052
  )
1053
+ update_parser.add_argument(
1054
+ "--force",
1055
+ action="store_true",
1056
+ help="Reinstall even if already at the latest version/commit",
1057
+ )
997
1058
 
998
1059
  init_parser = sub.add_parser(
999
1060
  "init",
@@ -1057,7 +1118,7 @@ def main() -> None:
1057
1118
  status(getattr(args, "config", None), as_json=getattr(args, "json", False))
1058
1119
  )
1059
1120
  elif args.command in ("update", "upgrade"):
1060
- update()
1121
+ update(force=getattr(args, "force", False))
1061
1122
  elif args.command == "init":
1062
1123
  from .init_wizard import run_init
1063
1124
 
@@ -54,7 +54,6 @@ import threading
54
54
  import time
55
55
  from urllib.parse import urlparse
56
56
 
57
- from . import attention, focus, interaction, layout, rendering, views
58
57
  from muxplex_client import (
59
58
  AuthError,
60
59
  MuxplexClient,
@@ -64,6 +63,8 @@ from muxplex_client import (
64
63
  Settings,
65
64
  UnreachableError,
66
65
  )
66
+
67
+ from . import attention, focus, interaction, layout, rendering, views
67
68
  from .config import Config
68
69
  from .device import (
69
70
  DeckDevice,
@@ -17,6 +17,21 @@ Ported near 1:1 from muxplex's own `service.py` (see that repo's
17
17
  (unit/plist path, enable/start, resulting status) using the same ✓/!
18
18
  2-space-indent style as `cli.doctor()` -- a silent success path left a real
19
19
  user unsure whether `service install` had done anything at all.
20
+
21
+ `launchctl bootstrap` idempotency: unlike `systemctl start` (idempotent --
22
+ starting an already-active unit just succeeds), `launchctl bootstrap` exits
23
+ 5 ("Input/output error") when the job label is ALREADY loaded. A real user
24
+ hit this running `service start` against a service that was already
25
+ healthy and running: the code used ``check=True``, so launchd's expected
26
+ "already loaded" refusal surfaced as an unhandled `CalledProcessError`
27
+ traceback instead of the benign no-op it actually is. `_launchd_bootstrap()`
28
+ is the shared, non-raising (`check=False`) helper both `_launchd_install()`
29
+ and `_launchd_start()` use; both treat exit 5 as success, and any other
30
+ nonzero exit as a genuine failure reported via launchctl's own stderr
31
+ (never swallowed) rather than a raw traceback. `_launchd_restart()` also
32
+ waits for `bootout` to actually finish (it returns before the job is fully
33
+ torn down) so the follow-up `bootstrap` doesn't race it into the same
34
+ exit-5 rejection for the wrong reason.
20
35
  """
21
36
 
22
37
  from __future__ import annotations
@@ -26,6 +41,7 @@ import os
26
41
  import shutil
27
42
  import subprocess
28
43
  import sys
44
+ import time
29
45
  from pathlib import Path
30
46
 
31
47
  # ---------------------------------------------------------------------------
@@ -39,6 +55,21 @@ _LAUNCHD_PLIST_DIR: Path = Path.home() / "Library" / "LaunchAgents"
39
55
  _LAUNCHD_PLIST_PATH: Path = _LAUNCHD_PLIST_DIR / "com.muxplex-deck.plist"
40
56
  _LAUNCHD_LABEL: str = "com.muxplex-deck"
41
57
 
58
+ # `launchctl bootstrap` exit code when the job label is already loaded. This
59
+ # is launchd's way of saying "already running" -- not a failure -- so it is
60
+ # handled as a benign no-op everywhere `_launchd_bootstrap()` is used, never
61
+ # swallowed silently for any OTHER exit code.
62
+ _LAUNCHD_ALREADY_LOADED_EXIT = 5
63
+
64
+ # `launchctl bootout` returns before the job has necessarily finished
65
+ # tearing down, so a `restart` (stop then start) can race it: bootstrap
66
+ # fires while the old job is still unloading and gets rejected with the
67
+ # same exit-5 "already loaded" the still-loading old job produces. Poll
68
+ # `service_is_active()` until the job is actually gone (bounded so a stuck
69
+ # teardown can't hang `restart` forever).
70
+ _LAUNCHD_BOOTOUT_POLL_INTERVAL_SECONDS = 0.2
71
+ _LAUNCHD_BOOTOUT_TIMEOUT_SECONDS = 5.0
72
+
42
73
  # Elgato Stream Deck USB vendor id -- used to detect an existing udev rule.
43
74
  _ELGATO_VENDOR_ID = "0fd9"
44
75
  _UDEV_RULE_DIRS: tuple[Path, ...] = (
@@ -342,8 +373,36 @@ def _systemd_uninstall() -> None:
342
373
  print()
343
374
 
344
375
 
376
+ def _report_systemctl_failure(
377
+ action: str, result: subprocess.CompletedProcess[str]
378
+ ) -> None:
379
+ """Print systemctl's own diagnostics for a genuine (non-idempotent) failure.
380
+
381
+ The most common ordinary-user trigger here is running `start`/`restart`
382
+ before `install` (unit not found) -- a real failure, but one that should
383
+ surface as a clear message, not a raw `CalledProcessError` traceback.
384
+ """
385
+ stderr = (result.stderr or "").strip()
386
+ print(
387
+ f" ERROR: systemctl {action} failed (exit {result.returncode})",
388
+ file=sys.stderr,
389
+ )
390
+ if stderr:
391
+ print(f" {stderr}", file=sys.stderr)
392
+
393
+
345
394
  def _systemd_start() -> None:
346
- subprocess.run(["systemctl", "--user", "start", "muxplex-deck"], check=True)
395
+ result = subprocess.run(
396
+ ["systemctl", "--user", "start", "muxplex-deck"],
397
+ capture_output=True,
398
+ text=True,
399
+ check=False,
400
+ )
401
+ if result.returncode == 0:
402
+ _step_ok("Started the service")
403
+ else:
404
+ _report_systemctl_failure("start", result)
405
+ sys.exit(1)
347
406
 
348
407
 
349
408
  def _systemd_stop() -> None:
@@ -351,7 +410,21 @@ def _systemd_stop() -> None:
351
410
 
352
411
 
353
412
  def _systemd_restart() -> None:
354
- subprocess.run(["systemctl", "--user", "restart", "muxplex-deck"], check=True)
413
+ # `systemctl restart` is a single atomic transaction (unlike launchd's
414
+ # separate bootout + bootstrap), so it needs no unload-race handling --
415
+ # and it is idempotent whether or not the unit was already running. The
416
+ # ordinary failure mode is the unit not being installed yet.
417
+ result = subprocess.run(
418
+ ["systemctl", "--user", "restart", "muxplex-deck"],
419
+ capture_output=True,
420
+ text=True,
421
+ check=False,
422
+ )
423
+ if result.returncode == 0:
424
+ _step_ok("Restarted the service")
425
+ else:
426
+ _report_systemctl_failure("restart", result)
427
+ sys.exit(1)
355
428
 
356
429
 
357
430
  def _systemd_status() -> None:
@@ -374,6 +447,71 @@ def _systemd_logs() -> None:
374
447
  # ---------------------------------------------------------------------------
375
448
 
376
449
 
450
+ def _launchd_bootstrap() -> subprocess.CompletedProcess[str]:
451
+ """Run `launchctl bootstrap` for the muxplex-deck plist, never raising.
452
+
453
+ `check=False` deliberately: `bootstrap` exits
454
+ `_LAUNCHD_ALREADY_LOADED_EXIT` (5, "Input/output error") when the job
455
+ label is already loaded -- launchd's way of saying "already running",
456
+ not a failure. Callers must inspect `returncode` themselves and decide
457
+ what that means for them; this helper only runs the command and hands
458
+ back the raw result.
459
+ """
460
+ uid = os.getuid()
461
+ return subprocess.run(
462
+ ["launchctl", "bootstrap", f"gui/{uid}", str(_LAUNCHD_PLIST_PATH)],
463
+ capture_output=True,
464
+ text=True,
465
+ check=False,
466
+ )
467
+
468
+
469
+ def _report_launchctl_failure(
470
+ action: str, result: subprocess.CompletedProcess[str]
471
+ ) -> None:
472
+ """Print launchctl's own diagnostics for a genuine (non-idempotent) failure.
473
+
474
+ Only the well-known "already loaded" exit code is treated as benign by
475
+ callers; every other nonzero exit prints launchctl's own stderr (which
476
+ already includes its "re-run as root for richer errors" hint) so the
477
+ real failure is still visible -- just as a clear message instead of an
478
+ unhandled `CalledProcessError` traceback.
479
+ """
480
+ stderr = (result.stderr or "").strip()
481
+ print(
482
+ f" ERROR: launchctl {action} failed (exit {result.returncode})",
483
+ file=sys.stderr,
484
+ )
485
+ if stderr:
486
+ print(f" {stderr}", file=sys.stderr)
487
+
488
+
489
+ def _wait_for_launchd_unload(timeout: float | None = None) -> bool:
490
+ """Poll until the launchd job is no longer loaded, or `timeout` elapses.
491
+
492
+ `launchctl bootout` returns before the job has necessarily finished
493
+ tearing down -- immediately re-bootstrapping afterward can race it and
494
+ get rejected with the same "already loaded" exit code a genuinely
495
+ still-running job would produce. Returns True once `service_is_active()`
496
+ reports the job gone; returns False if it was still present when the
497
+ timeout elapsed (the caller decides how to proceed -- see
498
+ `_launchd_restart`).
499
+
500
+ `timeout` defaults to `_LAUNCHD_BOOTOUT_TIMEOUT_SECONDS` read at CALL time
501
+ (not as a bound default argument) so tests can monkeypatch the module
502
+ constant and have it take effect.
503
+ """
504
+ if timeout is None:
505
+ timeout = _LAUNCHD_BOOTOUT_TIMEOUT_SECONDS
506
+ deadline = time.monotonic() + timeout
507
+ while True:
508
+ if not service_is_active():
509
+ return True
510
+ if time.monotonic() >= deadline:
511
+ return False
512
+ time.sleep(_LAUNCHD_BOOTOUT_POLL_INTERVAL_SECONDS)
513
+
514
+
377
515
  def _launchd_install() -> None:
378
516
  print("\nmuxplex-deck service install (launchd)\n")
379
517
 
@@ -394,11 +532,16 @@ def _launchd_install() -> None:
394
532
  _LAUNCHD_PLIST_PATH.write_text(plist_content)
395
533
  _step_ok(f"Wrote plist: {_LAUNCHD_PLIST_PATH}")
396
534
 
397
- uid = os.getuid()
398
- subprocess.run(
399
- ["launchctl", "bootstrap", f"gui/{uid}", str(_LAUNCHD_PLIST_PATH)], check=True
400
- )
401
- _step_ok("Loaded + started the service (launchctl bootstrap)")
535
+ result = _launchd_bootstrap()
536
+ if result.returncode == 0:
537
+ _step_ok("Loaded + started the service (launchctl bootstrap)")
538
+ elif result.returncode == _LAUNCHD_ALREADY_LOADED_EXIT:
539
+ _step_ok(
540
+ "Service was already loaded -- plist rewritten; run "
541
+ "`muxplex-deck service restart` to apply changes"
542
+ )
543
+ else:
544
+ _report_launchctl_failure("bootstrap", result)
402
545
 
403
546
  if service_is_active():
404
547
  _step_ok("Service is running")
@@ -434,10 +577,14 @@ def _launchd_uninstall() -> None:
434
577
 
435
578
 
436
579
  def _launchd_start() -> None:
437
- uid = os.getuid()
438
- subprocess.run(
439
- ["launchctl", "bootstrap", f"gui/{uid}", str(_LAUNCHD_PLIST_PATH)], check=True
440
- )
580
+ result = _launchd_bootstrap()
581
+ if result.returncode == 0:
582
+ _step_ok("Started the service (launchctl bootstrap)")
583
+ elif result.returncode == _LAUNCHD_ALREADY_LOADED_EXIT:
584
+ _step_ok("Service was already running (nothing to start)")
585
+ else:
586
+ _report_launchctl_failure("bootstrap", result)
587
+ sys.exit(1)
441
588
 
442
589
 
443
590
  def _launchd_stop() -> None:
@@ -447,7 +594,20 @@ def _launchd_stop() -> None:
447
594
 
448
595
  def _launchd_restart() -> None:
449
596
  _launchd_stop()
450
- _launchd_start()
597
+ if not _wait_for_launchd_unload():
598
+ _step_warn(
599
+ f"Service did not fully unload within "
600
+ f"{_LAUNCHD_BOOTOUT_TIMEOUT_SECONDS:.0f}s -- attempting restart anyway"
601
+ )
602
+
603
+ result = _launchd_bootstrap()
604
+ if result.returncode == 0:
605
+ _step_ok("Restarted the service (launchctl bootstrap)")
606
+ elif result.returncode == _LAUNCHD_ALREADY_LOADED_EXIT:
607
+ _step_ok("Service is running")
608
+ else:
609
+ _report_launchctl_failure("bootstrap", result)
610
+ sys.exit(1)
451
611
 
452
612
 
453
613
  def _launchd_status() -> None:
File without changes
@@ -37,11 +37,11 @@ from __future__ import annotations
37
37
  import re
38
38
  from typing import cast
39
39
 
40
+ from muxplex_client import Session
40
41
  from PIL import Image, ImageDraw, ImageFont
41
42
  from StreamDeck.Devices.StreamDeck import StreamDeck
42
43
  from StreamDeck.ImageHelpers import PILHelper
43
44
 
44
- from muxplex_client import Session
45
45
  from .device import DeckDevice
46
46
 
47
47
  _KEY_LABEL_FONT_SIZE = 16