muxplex-deck 0.5.2__tar.gz → 0.5.3__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 (29) hide show
  1. {muxplex_deck-0.5.2 → muxplex_deck-0.5.3}/PKG-INFO +1 -1
  2. {muxplex_deck-0.5.2 → muxplex_deck-0.5.3}/pyproject.toml +1 -1
  3. {muxplex_deck-0.5.2 → muxplex_deck-0.5.3}/src/muxplex_deck/cli.py +94 -2
  4. {muxplex_deck-0.5.2 → muxplex_deck-0.5.3}/src/muxplex_deck/init_wizard.py +46 -9
  5. {muxplex_deck-0.5.2 → muxplex_deck-0.5.3}/src/muxplex_deck/service.py +135 -5
  6. {muxplex_deck-0.5.2 → muxplex_deck-0.5.3}/README.md +0 -0
  7. {muxplex_deck-0.5.2 → muxplex_deck-0.5.3}/src/deck_probe/__init__.py +0 -0
  8. {muxplex_deck-0.5.2 → muxplex_deck-0.5.3}/src/deck_probe/__main__.py +0 -0
  9. {muxplex_deck-0.5.2 → muxplex_deck-0.5.3}/src/deck_probe/capabilities.py +0 -0
  10. {muxplex_deck-0.5.2 → muxplex_deck-0.5.3}/src/deck_probe/events.py +0 -0
  11. {muxplex_deck-0.5.2 → muxplex_deck-0.5.3}/src/deck_probe/main.py +0 -0
  12. {muxplex_deck-0.5.2 → muxplex_deck-0.5.3}/src/deck_probe/rendering.py +0 -0
  13. {muxplex_deck-0.5.2 → muxplex_deck-0.5.3}/src/muxplex_deck/__init__.py +0 -0
  14. {muxplex_deck-0.5.2 → muxplex_deck-0.5.3}/src/muxplex_deck/__main__.py +0 -0
  15. {muxplex_deck-0.5.2 → muxplex_deck-0.5.3}/src/muxplex_deck/attention.py +0 -0
  16. {muxplex_deck-0.5.2 → muxplex_deck-0.5.3}/src/muxplex_deck/config.py +0 -0
  17. {muxplex_deck-0.5.2 → muxplex_deck-0.5.3}/src/muxplex_deck/device.py +0 -0
  18. {muxplex_deck-0.5.2 → muxplex_deck-0.5.3}/src/muxplex_deck/device_real.py +0 -0
  19. {muxplex_deck-0.5.2 → muxplex_deck-0.5.3}/src/muxplex_deck/emulator.py +0 -0
  20. {muxplex_deck-0.5.2 → muxplex_deck-0.5.3}/src/muxplex_deck/focus.py +0 -0
  21. {muxplex_deck-0.5.2 → muxplex_deck-0.5.3}/src/muxplex_deck/hidhelp.py +0 -0
  22. {muxplex_deck-0.5.2 → muxplex_deck-0.5.3}/src/muxplex_deck/interaction.py +0 -0
  23. {muxplex_deck-0.5.2 → muxplex_deck-0.5.3}/src/muxplex_deck/layout.py +0 -0
  24. {muxplex_deck-0.5.2 → muxplex_deck-0.5.3}/src/muxplex_deck/main.py +0 -0
  25. {muxplex_deck-0.5.2 → muxplex_deck-0.5.3}/src/muxplex_deck/rendering.py +0 -0
  26. {muxplex_deck-0.5.2 → muxplex_deck-0.5.3}/src/muxplex_deck/statusfile.py +0 -0
  27. {muxplex_deck-0.5.2 → muxplex_deck-0.5.3}/src/muxplex_deck/usbnode.py +0 -0
  28. {muxplex_deck-0.5.2 → muxplex_deck-0.5.3}/src/muxplex_deck/views.py +0 -0
  29. {muxplex_deck-0.5.2 → muxplex_deck-0.5.3}/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.5.3
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.5.3"
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 = [
@@ -593,6 +593,60 @@ def check_server_reachable(server_url: str, ca_file: Path | None) -> tuple[str,
593
593
  return "warn", f"Server check failed: {exc}"
594
594
 
595
595
 
596
+ def check_federation_key_auth(
597
+ server_url: str, federation_key: str, *, verify: bool | str = True
598
+ ) -> tuple[str, str]:
599
+ """Issue ONE authenticated request to confirm `federation_key` is actually accepted.
600
+
601
+ `/api/instance-info` (what `check_server_reachable`/`fetch_instance_info`
602
+ call) is unauthenticated by design on the server -- it proves TLS and
603
+ reachability, but nothing about a credential. That gap is exactly what
604
+ let `init` once print a green "Server reachable" check, write config,
605
+ and offer to install the service for a federation key that was simply
606
+ wrong -- the user only found out once the running service couldn't
607
+ authenticate (see AGENTS.md).
608
+
609
+ This hits `GET /api/sessions` instead: authenticated, read-only, the
610
+ same lightweight endpoint the sidecar's own active loop already polls
611
+ (see `main.py`), with the key sent as `Authorization: Bearer` -- the
612
+ exact scheme the muxplex server's `auth.py` checks. Returns one of:
613
+
614
+ ("ok", ...) -- HTTP 200: the key is accepted.
615
+ ("fail", ...) -- HTTP 401: the key is definitively rejected.
616
+ Callers must not save a key that gets this back.
617
+ ("warn", ...) -- anything else (timeout, connection error,
618
+ unexpected status): could not verify one way or
619
+ the other. This is NOT evidence the key is bad --
620
+ callers must not print a false success, but also
621
+ must not treat an unrelated network hiccup as a
622
+ rejected key.
623
+ """
624
+ import httpx
625
+
626
+ url = f"{server_url.rstrip('/')}/api/sessions"
627
+ headers = {"Authorization": f"Bearer {federation_key}"}
628
+ try:
629
+ with httpx.Client(verify=verify, timeout=5.0) as client:
630
+ resp = client.get(url, headers=headers)
631
+ except httpx.TimeoutException as exc:
632
+ return (
633
+ "warn",
634
+ f"Could not verify federation key -- server check timed out: {exc}",
635
+ )
636
+ except httpx.ConnectError as exc:
637
+ return "warn", f"Could not verify federation key -- server unreachable: {exc}"
638
+ except Exception as exc: # noqa: BLE001 -- verification must degrade to warn, never raise
639
+ return "warn", f"Could not verify federation key: {exc}"
640
+
641
+ if resp.status_code == 200:
642
+ return "ok", "Federation key accepted by server"
643
+ if resp.status_code == 401:
644
+ return "fail", "Federation key rejected by server (401 Unauthorized)"
645
+ return "warn", (
646
+ f"Could not verify federation key -- unexpected response HTTP {resp.status_code}"
647
+ )
648
+
649
+
596
650
  def check_service_status() -> tuple[str, str]:
597
651
  """Service-state check: not installed / installed but not running / running.
598
652
 
@@ -629,6 +683,13 @@ _CHECK_MARKS: dict[str, str] = {
629
683
  "ok": "\033[32m\u2713\033[0m",
630
684
  "fail": "\033[31m\u2717\033[0m",
631
685
  "warn": "\033[33m!\033[0m",
686
+ # Distinct from "warn": "warn" means a check RAN and found something to
687
+ # flag; "unknown" means the check could not be run at all yet (e.g. the
688
+ # currently-running process hasn't published a status write of its own
689
+ # yet). Conflating the two is what let a dying process's stale-but-
690
+ # recent status snapshot get reported as definite failures right after
691
+ # `service restart` -- see AGENTS.md's restart-race incident.
692
+ "unknown": "\033[36m?\033[0m",
632
693
  }
633
694
 
634
695
 
@@ -789,7 +850,7 @@ def status(config_path: str | None = None, *, as_json: bool = False) -> int:
789
850
  fall back to a direct probe -- this keeps `status` useful even before
790
851
  the service has ever been installed.
791
852
  """
792
- from .service import service_is_active
853
+ from .service import service_is_active, service_main_pid
793
854
  from .statusfile import read_status
794
855
 
795
856
  running = service_is_active()
@@ -825,13 +886,44 @@ def status(config_path: str | None = None, *, as_json: bool = False) -> int:
825
886
 
826
887
  if data is None:
827
888
  print_check(
828
- "warn",
889
+ "unknown",
829
890
  "No status file found even though the service is running -- it "
830
891
  "may have just started. Try: muxplex-deck service logs",
831
892
  )
832
893
  print()
833
894
  return 0
834
895
 
896
+ # Is this snapshot from the process running RIGHT NOW, or from a
897
+ # PREVIOUS incarnation? Age alone can't tell: a process that's about to
898
+ # be replaced (e.g. by `service restart`) can write its LAST status
899
+ # moments before exiting, so it still looks "fresh" by age even though
900
+ # it no longer reflects reality -- exactly the restart-race incident in
901
+ # AGENTS.md (2 false failures reported from a dying process's stale-but-
902
+ # recent write). Comparing the recorded pid to the service manager's own
903
+ # live MainPID is the reliable signal; fall back to the age check only
904
+ # when the live pid can't be determined at all (unsupported platform,
905
+ # command failure).
906
+ current_pid = service_main_pid()
907
+ recorded_pid = data.get("pid")
908
+ if current_pid is not None:
909
+ is_current = recorded_pid == current_pid
910
+ else:
911
+ is_current = (
912
+ time.time() - data.get("updated_at", 0)
913
+ ) <= _STATUS_STALE_THRESHOLD_SECONDS
914
+
915
+ if not is_current:
916
+ print_check(
917
+ "unknown",
918
+ f"Status not yet available for the running process (pid "
919
+ f"{current_pid if current_pid is not None else '?'}) -- the "
920
+ f"published status is from a previous run (pid {recorded_pid}). "
921
+ "This is expected right after (re)starting; try again in a "
922
+ "moment, or: muxplex-deck service logs",
923
+ )
924
+ print()
925
+ return 0
926
+
835
927
  age = time.time() - data.get("updated_at", 0)
836
928
  if age > _STATUS_STALE_THRESHOLD_SECONDS:
837
929
  print_check(
@@ -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()
File without changes