muxplex-deck 0.4.0__py3-none-any.whl

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.
@@ -0,0 +1,551 @@
1
+ """muxplex_deck/service.py -- System service management (systemd on Linux, launchd on macOS).
2
+
3
+ Ported near 1:1 from muxplex's own `service.py` (see that repo's
4
+ `muxplex/service.py`), with two sidecar-specific differences:
5
+
6
+ 1. ``Restart=always`` (not muxplex's ``on-failure``) + a ``loginctl
7
+ enable-linger`` attempt on install -- this is a headless, always-on
8
+ sidecar meant to survive logout, not a service a user interactively
9
+ restarts.
10
+ 2. A udev-rule check on Linux install: by default a non-root user cannot
11
+ open the Stream Deck's HID device (this is why the sidecar is normally
12
+ run via ``sudo``), so `service_install()` warns loudly with a
13
+ copy-pasteable remediation block when no matching rule exists, rather
14
+ than silently installing a service that will fail to open the device.
15
+
16
+ `service_install()`/`service_uninstall()` narrate every step they take
17
+ (unit/plist path, enable/start, resulting status) using the same ✓/!
18
+ 2-space-indent style as `cli.doctor()` -- a silent success path left a real
19
+ user unsure whether `service install` had done anything at all.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import getpass
25
+ import os
26
+ import shutil
27
+ import subprocess
28
+ import sys
29
+ from pathlib import Path
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # Constants
33
+ # ---------------------------------------------------------------------------
34
+
35
+ _SYSTEMD_UNIT_DIR: Path = Path.home() / ".config" / "systemd" / "user"
36
+ _SYSTEMD_UNIT_PATH: Path = _SYSTEMD_UNIT_DIR / "muxplex-deck.service"
37
+
38
+ _LAUNCHD_PLIST_DIR: Path = Path.home() / "Library" / "LaunchAgents"
39
+ _LAUNCHD_PLIST_PATH: Path = _LAUNCHD_PLIST_DIR / "com.muxplex-deck.plist"
40
+ _LAUNCHD_LABEL: str = "com.muxplex-deck"
41
+
42
+ # Elgato Stream Deck USB vendor id -- used to detect an existing udev rule.
43
+ _ELGATO_VENDOR_ID = "0fd9"
44
+ _UDEV_RULE_DIRS: tuple[Path, ...] = (
45
+ Path("/etc/udev/rules.d"),
46
+ Path("/usr/lib/udev/rules.d"),
47
+ )
48
+
49
+ _SYSTEMD_UNIT_TEMPLATE = """\
50
+ [Unit]
51
+ Description=muxplex-deck
52
+ After=network.target
53
+
54
+ [Service]
55
+ Type=simple
56
+ ExecStart={exec_start}
57
+ Restart=always
58
+ RestartSec=5s
59
+ TimeoutStopSec=10
60
+ KillMode=mixed
61
+ Environment=PATH={safe_path}
62
+
63
+ [Install]
64
+ WantedBy=default.target
65
+ """
66
+
67
+ _LAUNCHD_PLIST_TEMPLATE = """\
68
+ <?xml version="1.0" encoding="UTF-8"?>
69
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
70
+ <plist version="1.0">
71
+ <dict>
72
+ <key>Label</key>
73
+ <string>{label}</string>
74
+ <key>ProgramArguments</key>
75
+ <array>
76
+ {program_arguments_xml}
77
+ </array>
78
+ <key>EnvironmentVariables</key>
79
+ <dict>
80
+ <key>PATH</key>
81
+ <string>{safe_path}</string>
82
+ </dict>
83
+ <key>RunAtLoad</key>
84
+ <true/>
85
+ <key>KeepAlive</key>
86
+ <true/>
87
+ <key>StandardOutPath</key>
88
+ <string>/tmp/muxplex-deck.log</string>
89
+ <key>StandardErrorPath</key>
90
+ <string>/tmp/muxplex-deck.err</string>
91
+ </dict>
92
+ </plist>
93
+ """
94
+
95
+ _UDEV_RULE_CONTENT = (
96
+ 'SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", MODE="0660", TAG+="uaccess"\n'
97
+ 'SUBSYSTEM=="hidraw", ATTRS{idVendor}=="0fd9", MODE="0660", TAG+="uaccess"\n'
98
+ )
99
+
100
+ _UDEV_REMEDIATION = f"""\
101
+ ! No udev rule found for the Stream Deck (vendor id {_ELGATO_VENDOR_ID}).
102
+ Without it, the service (running as your user, not root) will fail to
103
+ open the device. Install a rule once:
104
+
105
+ sudo tee /etc/udev/rules.d/70-streamdeck.rules >/dev/null <<'EOF'
106
+ {_UDEV_RULE_CONTENT}EOF
107
+ sudo udevadm control --reload-rules && sudo udevadm trigger
108
+
109
+ Then unplug and replug the Stream Deck (or re-run `usbipd attach` under WSL).
110
+ """
111
+
112
+ # Same ✓/! 2-space-indent style as `cli.doctor()`'s `print_check` -- kept as
113
+ # a small local duplicate (rather than importing from `cli.py`) to avoid a
114
+ # circular import: `cli.py` already imports from this module at call time.
115
+ _MARK_OK = "\033[32m\u2713\033[0m"
116
+ _MARK_WARN = "\033[33m!\033[0m"
117
+
118
+
119
+ def _step_ok(message: str) -> None:
120
+ print(f" {_MARK_OK} {message}")
121
+
122
+
123
+ def _step_warn(message: str) -> None:
124
+ print(f" {_MARK_WARN} {message}")
125
+
126
+
127
+ # ---------------------------------------------------------------------------
128
+ # Platform detection
129
+ # ---------------------------------------------------------------------------
130
+
131
+
132
+ def _is_darwin() -> bool:
133
+ """Return True if running on macOS."""
134
+ return sys.platform == "darwin"
135
+
136
+
137
+ def _have_systemctl() -> bool:
138
+ """Return True if systemctl is on PATH (gates all systemd service operations)."""
139
+ return shutil.which("systemctl") is not None
140
+
141
+
142
+ def _resolve_muxplex_deck_bin() -> str:
143
+ """Return the muxplex-deck binary path (joined string -- systemd splits it).
144
+
145
+ Prefers the ``muxplex-deck`` executable on PATH; falls back to
146
+ ``<sys.executable> -m muxplex_deck`` when not found.
147
+ """
148
+ which = shutil.which("muxplex-deck")
149
+ if which:
150
+ return which
151
+ return f"{sys.executable} -m muxplex_deck"
152
+
153
+
154
+ def _resolve_bin_for_launchd() -> list[str]:
155
+ """Return the argv token list for the muxplex-deck binary in a launchd plist.
156
+
157
+ Prefers ``~/.local/bin/muxplex-deck`` (stable uv-tool console-script
158
+ symlink that survives ``uv tool reinstall``). Falls back to
159
+ ``shutil.which("muxplex-deck")``, then to ``[sys.executable, "-m",
160
+ "muxplex_deck"]`` as explicitly split tokens.
161
+
162
+ Each element must become its own ``<string>`` in ProgramArguments --
163
+ launchd does **not** shell-split inside a ``<string>``; an element like
164
+ ``"python3 -m muxplex_deck"`` is treated as a literal executable name,
165
+ causing the daemon to silently fail to start.
166
+ """
167
+ local_bin = Path.home() / ".local" / "bin" / "muxplex-deck"
168
+ if local_bin.exists() and os.access(str(local_bin), os.X_OK):
169
+ return [str(local_bin)]
170
+
171
+ which = shutil.which("muxplex-deck")
172
+ if which:
173
+ return [which]
174
+
175
+ return [sys.executable, "-m", "muxplex_deck"]
176
+
177
+
178
+ # ---------------------------------------------------------------------------
179
+ # udev rule detection (Linux HID-permission caveat)
180
+ # ---------------------------------------------------------------------------
181
+
182
+
183
+ def udev_rule_exists() -> bool:
184
+ """Return True if any udev rule file mentions the Elgato vendor id.
185
+
186
+ Scans `/etc/udev/rules.d/` and `/usr/lib/udev/rules.d/` for a `*.rules`
187
+ file whose contents mention vendor id `0fd9` (case-insensitive). Never
188
+ writes to `/etc` itself -- only reports.
189
+ """
190
+ for rules_dir in _UDEV_RULE_DIRS:
191
+ if not rules_dir.is_dir():
192
+ continue
193
+ for rule_file in rules_dir.glob("*.rules"):
194
+ try:
195
+ text = rule_file.read_text(encoding="utf-8", errors="ignore")
196
+ except OSError:
197
+ continue
198
+ if _ELGATO_VENDOR_ID in text.lower():
199
+ return True
200
+ return False
201
+
202
+
203
+ def _warn_if_no_udev_rule() -> None:
204
+ """Print the udev remediation block (non-fatal) if no rule is present."""
205
+ if not udev_rule_exists():
206
+ print(_UDEV_REMEDIATION)
207
+
208
+
209
+ def service_is_active() -> bool:
210
+ """Best-effort: is the muxplex-deck service currently active/running?
211
+
212
+ Public (moved here from `cli._service_is_active`) so both `cli.update()`
213
+ and `cli.check_hid_openable()` share one implementation -- the latter
214
+ needs it to distinguish "our own service holds the device" (expected,
215
+ not a failure) from a genuine HID-permission problem. Never raises:
216
+ a missing service manager or a not-installed service both read as
217
+ "not active", which is the correct doctor/status answer either way.
218
+ """
219
+ if _is_darwin():
220
+ uid = os.getuid()
221
+ try:
222
+ result = subprocess.run(
223
+ ["launchctl", "print", f"gui/{uid}/{_LAUNCHD_LABEL}"],
224
+ capture_output=True,
225
+ text=True,
226
+ check=False,
227
+ )
228
+ except FileNotFoundError:
229
+ return False
230
+ return result.returncode == 0
231
+
232
+ try:
233
+ result = subprocess.run(
234
+ ["systemctl", "--user", "is-active", "muxplex-deck"],
235
+ capture_output=True,
236
+ text=True,
237
+ check=False,
238
+ )
239
+ except FileNotFoundError:
240
+ return False
241
+ return result.returncode == 0
242
+
243
+
244
+ def _enable_linger() -> None:
245
+ """Best-effort `loginctl enable-linger` so the service survives logout.
246
+
247
+ muxplex has no analog to this -- it's a normal user-triggered server, not
248
+ a headless always-on sidecar. Failure (no loginctl, no systemd-logind,
249
+ permission denied) is reported but never fatal to install.
250
+ """
251
+ if shutil.which("loginctl") is None:
252
+ print(" ! loginctl not found -- skipping enable-linger (service may")
253
+ print(" stop when you log out; install systemd-logind or enable")
254
+ print(" lingering manually if this is a headless always-on box)")
255
+ return
256
+ user = getpass.getuser()
257
+ result = subprocess.run(
258
+ ["loginctl", "enable-linger", user],
259
+ capture_output=True,
260
+ text=True,
261
+ check=False,
262
+ )
263
+ if result.returncode == 0:
264
+ print(f" Linger enabled for {user} (service survives logout)")
265
+ else:
266
+ print(f" ! Could not enable linger for {user}: {result.stderr.strip()}")
267
+ print(" The service may stop when you log out of this session.")
268
+
269
+
270
+ # ---------------------------------------------------------------------------
271
+ # Private implementations -- systemd (Linux)
272
+ # ---------------------------------------------------------------------------
273
+
274
+
275
+ def _print_next_steps() -> None:
276
+ print()
277
+ print(" Next:")
278
+ print(" muxplex-deck status -- see connected hardware + connection state")
279
+ print(" muxplex-deck service logs -- tail live logs")
280
+ print()
281
+
282
+
283
+ def _systemd_install() -> None:
284
+ print("\nmuxplex-deck service install (systemd --user)\n")
285
+
286
+ bin_path = _resolve_muxplex_deck_bin()
287
+ safe_path = os.environ.get("PATH", "/usr/local/bin:/usr/bin:/bin")
288
+ exec_start = f"{bin_path} run"
289
+ unit_content = _SYSTEMD_UNIT_TEMPLATE.format(
290
+ exec_start=exec_start, safe_path=safe_path
291
+ )
292
+ _SYSTEMD_UNIT_DIR.mkdir(parents=True, exist_ok=True)
293
+ _SYSTEMD_UNIT_PATH.write_text(unit_content)
294
+ _step_ok(f"Wrote unit file: {_SYSTEMD_UNIT_PATH}")
295
+
296
+ subprocess.run(["systemctl", "--user", "daemon-reload"], check=True)
297
+ _step_ok("Reloaded the systemd user daemon")
298
+
299
+ subprocess.run(
300
+ ["systemctl", "--user", "enable", "--now", "muxplex-deck"], check=True
301
+ )
302
+ _step_ok("Enabled + started the service")
303
+
304
+ _enable_linger()
305
+ _warn_if_no_udev_rule()
306
+
307
+ if service_is_active():
308
+ _step_ok("Service is running")
309
+ else:
310
+ _step_warn(
311
+ "Service was started but is not reporting active -- check: "
312
+ "muxplex-deck service logs"
313
+ )
314
+
315
+ _print_next_steps()
316
+
317
+
318
+ def _systemd_uninstall() -> None:
319
+ print("\nmuxplex-deck service uninstall (systemd --user)\n")
320
+
321
+ result = subprocess.run(
322
+ ["systemctl", "--user", "stop", "muxplex-deck"], check=False
323
+ )
324
+ if result.returncode == 0:
325
+ _step_ok("Stopped the service")
326
+ else:
327
+ _step_warn("Service was not running (nothing to stop)")
328
+
329
+ subprocess.run(["systemctl", "--user", "disable", "muxplex-deck"], check=False)
330
+ _step_ok("Disabled the service")
331
+
332
+ had_unit = _SYSTEMD_UNIT_PATH.exists()
333
+ _SYSTEMD_UNIT_PATH.unlink(missing_ok=True)
334
+ _step_ok(
335
+ f"Removed unit file: {_SYSTEMD_UNIT_PATH}"
336
+ if had_unit
337
+ else "Unit file already absent"
338
+ )
339
+
340
+ subprocess.run(["systemctl", "--user", "daemon-reload"], check=True)
341
+ _step_ok("Reloaded the systemd user daemon")
342
+ print()
343
+
344
+
345
+ def _systemd_start() -> None:
346
+ subprocess.run(["systemctl", "--user", "start", "muxplex-deck"], check=True)
347
+
348
+
349
+ def _systemd_stop() -> None:
350
+ subprocess.run(["systemctl", "--user", "stop", "muxplex-deck"], check=False)
351
+
352
+
353
+ def _systemd_restart() -> None:
354
+ subprocess.run(["systemctl", "--user", "restart", "muxplex-deck"], check=True)
355
+
356
+
357
+ def _systemd_status() -> None:
358
+ subprocess.run(
359
+ ["systemctl", "--user", "status", "muxplex-deck", "--no-pager"], check=False
360
+ )
361
+
362
+
363
+ def _systemd_logs() -> None:
364
+ try:
365
+ subprocess.run(
366
+ ["journalctl", "--user", "-u", "muxplex-deck", "-f"], check=False
367
+ )
368
+ except KeyboardInterrupt:
369
+ pass
370
+
371
+
372
+ # ---------------------------------------------------------------------------
373
+ # Private implementations -- launchd (macOS)
374
+ # ---------------------------------------------------------------------------
375
+
376
+
377
+ def _launchd_install() -> None:
378
+ print("\nmuxplex-deck service install (launchd)\n")
379
+
380
+ bin_args = _resolve_bin_for_launchd()
381
+ argv = [*bin_args, "run"]
382
+ # Each argv token is its own <string> element. launchd does NOT
383
+ # shell-split inside a <string>, so we must NOT put the whole command
384
+ # (e.g. "python3 -m muxplex_deck") into a single element.
385
+ program_arguments_xml = "\n".join(f" <string>{arg}</string>" for arg in argv)
386
+ base_path = os.environ.get("PATH", "/usr/bin:/bin")
387
+ safe_path = f"/opt/homebrew/bin:/usr/local/bin:{base_path}"
388
+ plist_content = _LAUNCHD_PLIST_TEMPLATE.format(
389
+ label=_LAUNCHD_LABEL,
390
+ program_arguments_xml=program_arguments_xml,
391
+ safe_path=safe_path,
392
+ )
393
+ _LAUNCHD_PLIST_DIR.mkdir(parents=True, exist_ok=True)
394
+ _LAUNCHD_PLIST_PATH.write_text(plist_content)
395
+ _step_ok(f"Wrote plist: {_LAUNCHD_PLIST_PATH}")
396
+
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)")
402
+
403
+ if service_is_active():
404
+ _step_ok("Service is running")
405
+ else:
406
+ _step_warn(
407
+ "Service was started but is not reporting active -- check: "
408
+ "muxplex-deck service logs"
409
+ )
410
+
411
+ _print_next_steps()
412
+
413
+
414
+ def _launchd_uninstall() -> None:
415
+ print("\nmuxplex-deck service uninstall (launchd)\n")
416
+
417
+ uid = os.getuid()
418
+ result = subprocess.run(
419
+ ["launchctl", "bootout", f"gui/{uid}/{_LAUNCHD_LABEL}"], check=False
420
+ )
421
+ if result.returncode == 0:
422
+ _step_ok("Stopped + unloaded the service")
423
+ else:
424
+ _step_warn("Service was not loaded (nothing to unload)")
425
+
426
+ had_plist = _LAUNCHD_PLIST_PATH.exists()
427
+ _LAUNCHD_PLIST_PATH.unlink(missing_ok=True)
428
+ _step_ok(
429
+ f"Removed plist: {_LAUNCHD_PLIST_PATH}"
430
+ if had_plist
431
+ else "Plist file already absent"
432
+ )
433
+ print()
434
+
435
+
436
+ def _launchd_start() -> None:
437
+ uid = os.getuid()
438
+ subprocess.run(
439
+ ["launchctl", "bootstrap", f"gui/{uid}", str(_LAUNCHD_PLIST_PATH)], check=True
440
+ )
441
+
442
+
443
+ def _launchd_stop() -> None:
444
+ uid = os.getuid()
445
+ subprocess.run(["launchctl", "bootout", f"gui/{uid}/{_LAUNCHD_LABEL}"], check=False)
446
+
447
+
448
+ def _launchd_restart() -> None:
449
+ _launchd_stop()
450
+ _launchd_start()
451
+
452
+
453
+ def _launchd_status() -> None:
454
+ uid = os.getuid()
455
+ subprocess.run(["launchctl", "print", f"gui/{uid}/{_LAUNCHD_LABEL}"], check=False)
456
+
457
+
458
+ def _launchd_logs() -> None:
459
+ try:
460
+ subprocess.run(["tail", "-f", "/tmp/muxplex-deck.log"], check=False)
461
+ except KeyboardInterrupt:
462
+ pass
463
+
464
+
465
+ # ---------------------------------------------------------------------------
466
+ # Public API -- platform-dispatching wrappers
467
+ # ---------------------------------------------------------------------------
468
+
469
+
470
+ def _unsupported_platform_error(command: str) -> None:
471
+ """Print a clear error when neither launchd nor systemd is available."""
472
+ print(
473
+ f" ERROR: 'muxplex-deck service {command}' requires systemd (Linux) or "
474
+ "launchd (macOS), neither of which was found.",
475
+ file=sys.stderr,
476
+ )
477
+ print(
478
+ " Run muxplex-deck directly to start the sidecar without a service manager:",
479
+ file=sys.stderr,
480
+ )
481
+ print(" muxplex-deck run", file=sys.stderr)
482
+
483
+
484
+ def service_install() -> None:
485
+ """Install the muxplex-deck service unit for the current user."""
486
+ if _is_darwin():
487
+ _launchd_install()
488
+ elif _have_systemctl():
489
+ _systemd_install()
490
+ else:
491
+ _unsupported_platform_error("install")
492
+
493
+
494
+ def service_uninstall() -> None:
495
+ """Remove the muxplex-deck service unit for the current user."""
496
+ if _is_darwin():
497
+ _launchd_uninstall()
498
+ elif _have_systemctl():
499
+ _systemd_uninstall()
500
+ else:
501
+ _unsupported_platform_error("uninstall")
502
+
503
+
504
+ def service_start() -> None:
505
+ """Start the muxplex-deck service."""
506
+ if _is_darwin():
507
+ _launchd_start()
508
+ elif _have_systemctl():
509
+ _systemd_start()
510
+ else:
511
+ _unsupported_platform_error("start")
512
+
513
+
514
+ def service_stop() -> None:
515
+ """Stop the muxplex-deck service."""
516
+ if _is_darwin():
517
+ _launchd_stop()
518
+ elif _have_systemctl():
519
+ _systemd_stop()
520
+ else:
521
+ _unsupported_platform_error("stop")
522
+
523
+
524
+ def service_restart() -> None:
525
+ """Restart the muxplex-deck service."""
526
+ if _is_darwin():
527
+ _launchd_restart()
528
+ elif _have_systemctl():
529
+ _systemd_restart()
530
+ else:
531
+ _unsupported_platform_error("restart")
532
+
533
+
534
+ def service_status() -> None:
535
+ """Print the current status of the muxplex-deck service."""
536
+ if _is_darwin():
537
+ _launchd_status()
538
+ elif _have_systemctl():
539
+ _systemd_status()
540
+ else:
541
+ _unsupported_platform_error("status")
542
+
543
+
544
+ def service_logs() -> None:
545
+ """Stream or print logs for the muxplex-deck service."""
546
+ if _is_darwin():
547
+ _launchd_logs()
548
+ elif _have_systemctl():
549
+ _systemd_logs()
550
+ else:
551
+ _unsupported_platform_error("logs")