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.
- deck_probe/__init__.py +14 -0
- deck_probe/__main__.py +6 -0
- deck_probe/capabilities.py +130 -0
- deck_probe/events.py +211 -0
- deck_probe/main.py +220 -0
- deck_probe/rendering.py +166 -0
- muxplex_deck/__init__.py +5 -0
- muxplex_deck/__main__.py +11 -0
- muxplex_deck/attention.py +81 -0
- muxplex_deck/cli.py +1122 -0
- muxplex_deck/config.py +267 -0
- muxplex_deck/device.py +101 -0
- muxplex_deck/device_real.py +157 -0
- muxplex_deck/emulator.py +530 -0
- muxplex_deck/focus.py +92 -0
- muxplex_deck/init_wizard.py +382 -0
- muxplex_deck/interaction.py +418 -0
- muxplex_deck/layout.py +211 -0
- muxplex_deck/main.py +1093 -0
- muxplex_deck/rendering.py +440 -0
- muxplex_deck/service.py +551 -0
- muxplex_deck/statusfile.py +176 -0
- muxplex_deck/views.py +92 -0
- muxplex_deck-0.4.0.dist-info/METADATA +639 -0
- muxplex_deck-0.4.0.dist-info/RECORD +27 -0
- muxplex_deck-0.4.0.dist-info/WHEEL +4 -0
- muxplex_deck-0.4.0.dist-info/entry_points.txt +4 -0
muxplex_deck/service.py
ADDED
|
@@ -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")
|