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
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
"""`muxplex-deck init` -- turnkey interactive setup wizard.
|
|
2
|
+
|
|
3
|
+
Closes the gap between "just installed" and "actually working" without
|
|
4
|
+
requiring SSH access to the muxplex server. muxplex's own CLI auto-derives
|
|
5
|
+
everything on first run (password, signing secret, device_id); this wizard
|
|
6
|
+
gives the sidecar the same experience for the two things it genuinely can't
|
|
7
|
+
invent (a server URL and a shared secret), while auto-fetching the CA
|
|
8
|
+
certificate wherever possible instead of requiring an `scp`.
|
|
9
|
+
|
|
10
|
+
Flow (see README.md "Quickstart" for the user-facing summary):
|
|
11
|
+
|
|
12
|
+
1. Server URL -- prompt (or take it as a positional arg / existing config).
|
|
13
|
+
2. Validate -- `GET /api/instance-info`. A TLS/cert failure is *expected*
|
|
14
|
+
for a server on its own local CA and just means "keep going, we'll fetch
|
|
15
|
+
or ask for the CA next" rather than a fatal error.
|
|
16
|
+
3. CA certificate -- try `GET /api/ca` (verify disabled for this one
|
|
17
|
+
bootstrap fetch: a CA public cert is a trust anchor, not a secret) first;
|
|
18
|
+
fall back to a validated local-path prompt only if that 404s AND TLS was
|
|
19
|
+
actually failing; skip entirely if TLS already works without one.
|
|
20
|
+
4. Federation key -- reuse an existing valid key file if present, otherwise
|
|
21
|
+
prompt to paste it (never echoed, never logged) or read it from a local
|
|
22
|
+
path.
|
|
23
|
+
5. Write config.json via the existing `config.patch_raw_config` (merges,
|
|
24
|
+
never clobbers unrelated keys).
|
|
25
|
+
6. Re-verify server reachability + deck detection with the new config in
|
|
26
|
+
place, using the same `doctor`-style check functions and formatting.
|
|
27
|
+
7. Print udev remediation if needed, offer to run `service install`.
|
|
28
|
+
|
|
29
|
+
Every step reuses the pure check helpers already in `cli.py`
|
|
30
|
+
(`check_server_reachable`, `check_ca_file`, `check_federation_key`,
|
|
31
|
+
`check_deck_detected`, `check_hid_openable`) and `service.udev_rule_exists()`
|
|
32
|
+
rather than duplicating their logic -- this module only adds the
|
|
33
|
+
interactive orchestration and the two raw data-fetch primitives
|
|
34
|
+
(`cli.fetch_instance_info`, `cli.fetch_ca_cert`) that check_server_reachable
|
|
35
|
+
alone doesn't expose.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import getpass
|
|
41
|
+
import hashlib
|
|
42
|
+
import sys
|
|
43
|
+
from collections.abc import Callable
|
|
44
|
+
from pathlib import Path
|
|
45
|
+
|
|
46
|
+
from . import cli as cli_mod
|
|
47
|
+
from . import config as config_mod
|
|
48
|
+
from . import service as service_mod
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class _InitError(Exception):
|
|
52
|
+
"""Raised internally to abort the wizard with a clear, already-final message."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _normalize_server_url(url: str) -> str:
|
|
56
|
+
"""Add `https://` if no scheme was given; strip a trailing slash."""
|
|
57
|
+
url = url.strip()
|
|
58
|
+
if "://" not in url:
|
|
59
|
+
url = f"https://{url}"
|
|
60
|
+
return url.rstrip("/")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _sha256_fingerprint(cert_bytes: bytes) -> str:
|
|
64
|
+
digest = hashlib.sha256(cert_bytes).hexdigest().upper()
|
|
65
|
+
return ":".join(digest[i : i + 2] for i in range(0, len(digest), 2))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _prompt(
|
|
69
|
+
label: str, *, default: str | None, input_func: Callable[[str], str]
|
|
70
|
+
) -> str:
|
|
71
|
+
"""Prompt with an optional default shown in brackets; Enter keeps it."""
|
|
72
|
+
if default:
|
|
73
|
+
raw = input_func(f"{label} [{default}]: ")
|
|
74
|
+
else:
|
|
75
|
+
raw = input_func(f"{label}: ")
|
|
76
|
+
raw = raw.strip()
|
|
77
|
+
return raw if raw else (default or "")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _confirm(label: str, *, default: bool, input_func: Callable[[str], str]) -> bool:
|
|
81
|
+
suffix = "[Y/n]" if default else "[y/N]"
|
|
82
|
+
raw = input_func(f"{label} {suffix}: ").strip().lower()
|
|
83
|
+
if not raw:
|
|
84
|
+
return default
|
|
85
|
+
return raw in ("y", "yes")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
# Step 2: validate the server (with a re-prompt loop on plain connection errors)
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _validate_server(
|
|
94
|
+
server_url: str,
|
|
95
|
+
*,
|
|
96
|
+
non_interactive: bool,
|
|
97
|
+
input_func: Callable[[str], str],
|
|
98
|
+
) -> tuple[str, dict | None, bool]:
|
|
99
|
+
"""Returns (possibly-updated server_url, instance_info or None, tls_needed)."""
|
|
100
|
+
current = server_url
|
|
101
|
+
while True:
|
|
102
|
+
print(f"\nChecking {current} ...")
|
|
103
|
+
try:
|
|
104
|
+
data = cli_mod.fetch_instance_info(current, verify=True)
|
|
105
|
+
except Exception as exc:
|
|
106
|
+
if cli_mod._is_tls_error(exc):
|
|
107
|
+
print(f" ! TLS verification failed: {exc}")
|
|
108
|
+
print(
|
|
109
|
+
" This server may be using its own local CA -- continuing "
|
|
110
|
+
"to fetch/configure it next."
|
|
111
|
+
)
|
|
112
|
+
return current, None, True
|
|
113
|
+
print(f" Could not reach {current}: {exc}")
|
|
114
|
+
if non_interactive:
|
|
115
|
+
raise _InitError(f"server unreachable: {exc}") from exc
|
|
116
|
+
current = _prompt("Server URL", default=current, input_func=input_func)
|
|
117
|
+
if not current:
|
|
118
|
+
raise _InitError("a server URL is required")
|
|
119
|
+
current = _normalize_server_url(current)
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
name = data.get("name", "?")
|
|
123
|
+
version = data.get("version", "?")
|
|
124
|
+
print(f" Found muxplex '{name}' running v{version}")
|
|
125
|
+
if data.get("federation_enabled") is False:
|
|
126
|
+
print(
|
|
127
|
+
" ! Federation is not enabled on this server yet -- run "
|
|
128
|
+
"`muxplex generate-federation-key` on the server first, then "
|
|
129
|
+
"re-run this wizard."
|
|
130
|
+
)
|
|
131
|
+
return current, data, False
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ---------------------------------------------------------------------------
|
|
135
|
+
# Step 3: CA certificate
|
|
136
|
+
# ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _resolve_ca(
|
|
140
|
+
server_url: str,
|
|
141
|
+
*,
|
|
142
|
+
existing_ca: str | None,
|
|
143
|
+
tls_needed: bool,
|
|
144
|
+
non_interactive: bool,
|
|
145
|
+
input_func: Callable[[str], str],
|
|
146
|
+
) -> str | None:
|
|
147
|
+
try:
|
|
148
|
+
ca_bytes = cli_mod.fetch_ca_cert(server_url)
|
|
149
|
+
except Exception as exc: # noqa: BLE001 -- network/HTTP errors from the bootstrap fetch
|
|
150
|
+
print(f" ! Could not query {server_url}/api/ca: {exc}")
|
|
151
|
+
ca_bytes = None
|
|
152
|
+
|
|
153
|
+
if ca_bytes:
|
|
154
|
+
ca_path = config_mod._expand("~/.config/muxplex-deck/muxplex-ca.crt")
|
|
155
|
+
ca_path.parent.mkdir(parents=True, exist_ok=True)
|
|
156
|
+
ca_path.write_bytes(ca_bytes)
|
|
157
|
+
ca_path.chmod(0o644)
|
|
158
|
+
fingerprint = _sha256_fingerprint(ca_bytes)
|
|
159
|
+
print(f" CA certificate fetched and saved: {ca_path}")
|
|
160
|
+
print(f" SHA-256 fingerprint: {fingerprint}")
|
|
161
|
+
print(
|
|
162
|
+
" Verify out-of-band on the server if you'd like:\n"
|
|
163
|
+
" openssl x509 -in ~/.config/muxplex/ca/muxplex-ca.crt "
|
|
164
|
+
"-noout -fingerprint -sha256"
|
|
165
|
+
)
|
|
166
|
+
return str(ca_path)
|
|
167
|
+
|
|
168
|
+
if not tls_needed:
|
|
169
|
+
print(" CA: not needed (server certificate verifies without one)")
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
print(
|
|
173
|
+
" ! No CA available at /api/ca, but TLS verification failed -- "
|
|
174
|
+
"this server needs a CA file and we can't self-serve it."
|
|
175
|
+
)
|
|
176
|
+
if non_interactive:
|
|
177
|
+
raise _InitError(
|
|
178
|
+
"TLS verification failed and no CA certificate could be "
|
|
179
|
+
"auto-fetched. Provide 'ca_file' in config.json, or re-run "
|
|
180
|
+
"`muxplex-deck init` interactively."
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
default_path = existing_ca
|
|
184
|
+
while True:
|
|
185
|
+
entered = _prompt(
|
|
186
|
+
"Path to the server's CA certificate file",
|
|
187
|
+
default=default_path,
|
|
188
|
+
input_func=input_func,
|
|
189
|
+
)
|
|
190
|
+
if not entered:
|
|
191
|
+
raise _InitError(
|
|
192
|
+
"a CA certificate path is required (TLS verification is failing)"
|
|
193
|
+
)
|
|
194
|
+
candidate = config_mod._expand(entered)
|
|
195
|
+
status, message = cli_mod.check_ca_file(candidate)
|
|
196
|
+
if status == "ok":
|
|
197
|
+
return str(candidate)
|
|
198
|
+
print(f" ! {message}")
|
|
199
|
+
default_path = entered
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# ---------------------------------------------------------------------------
|
|
203
|
+
# Step 4: federation key -- never printed, never logged
|
|
204
|
+
# ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _resolve_federation_key(
|
|
208
|
+
key_file_path: Path,
|
|
209
|
+
*,
|
|
210
|
+
non_interactive: bool,
|
|
211
|
+
input_func: Callable[[str], str],
|
|
212
|
+
getpass_func: Callable[[str], str],
|
|
213
|
+
) -> None:
|
|
214
|
+
if key_file_path.exists() and key_file_path.stat().st_size > 0:
|
|
215
|
+
if non_interactive:
|
|
216
|
+
print(f" Federation key: keeping existing {key_file_path}")
|
|
217
|
+
return
|
|
218
|
+
if _confirm(
|
|
219
|
+
f"Federation key already exists at {key_file_path}. Keep it?",
|
|
220
|
+
default=True,
|
|
221
|
+
input_func=input_func,
|
|
222
|
+
):
|
|
223
|
+
print(f" Federation key: keeping existing {key_file_path}")
|
|
224
|
+
return
|
|
225
|
+
|
|
226
|
+
if non_interactive:
|
|
227
|
+
raise _InitError(
|
|
228
|
+
f"federation key not found at {key_file_path}. Paste it "
|
|
229
|
+
"interactively once (re-run without --non-interactive), or "
|
|
230
|
+
f"copy it there yourself first, e.g.:\n"
|
|
231
|
+
f" scp <your-server>:.config/muxplex/federation_key {key_file_path}"
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
print(" Paste the federation key, or enter a local file path containing it.")
|
|
235
|
+
print(
|
|
236
|
+
" Get it from the server with `muxplex generate-federation-key`, or "
|
|
237
|
+
"read ~/.config/muxplex/federation_key there. SSH fallback:"
|
|
238
|
+
)
|
|
239
|
+
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
|
+
|
|
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
|
|
248
|
+
|
|
249
|
+
if not key_value:
|
|
250
|
+
raise _InitError("no federation key provided")
|
|
251
|
+
|
|
252
|
+
key_file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
253
|
+
key_file_path.parent.chmod(0o700)
|
|
254
|
+
key_file_path.write_text(key_value + "\n", encoding="utf-8")
|
|
255
|
+
key_file_path.chmod(0o600)
|
|
256
|
+
print(f" Federation key written: {key_file_path} (mode 0600)")
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
# ---------------------------------------------------------------------------
|
|
260
|
+
# Orchestration
|
|
261
|
+
# ---------------------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _run_init_impl(
|
|
265
|
+
config_path: str | None,
|
|
266
|
+
server_url_arg: str | None,
|
|
267
|
+
*,
|
|
268
|
+
non_interactive: bool,
|
|
269
|
+
input_func: Callable[[str], str],
|
|
270
|
+
getpass_func: Callable[[str], str],
|
|
271
|
+
) -> int:
|
|
272
|
+
raw = config_mod.load_raw_config(config_path)
|
|
273
|
+
|
|
274
|
+
print("\nmuxplex-deck init\n")
|
|
275
|
+
|
|
276
|
+
# Step 1: server URL
|
|
277
|
+
existing_url = raw.get("server_url") or None
|
|
278
|
+
if server_url_arg:
|
|
279
|
+
server_url = server_url_arg
|
|
280
|
+
elif non_interactive:
|
|
281
|
+
if not existing_url:
|
|
282
|
+
raise _InitError(
|
|
283
|
+
"SERVER_URL is required in --non-interactive mode, e.g.:\n"
|
|
284
|
+
" muxplex-deck init https://your-server:8088 --non-interactive"
|
|
285
|
+
)
|
|
286
|
+
server_url = existing_url
|
|
287
|
+
else:
|
|
288
|
+
entered = _prompt("Server URL", default=existing_url, input_func=input_func)
|
|
289
|
+
if not entered:
|
|
290
|
+
raise _InitError("a server URL is required")
|
|
291
|
+
server_url = entered
|
|
292
|
+
server_url = _normalize_server_url(server_url)
|
|
293
|
+
|
|
294
|
+
# Step 2: validate
|
|
295
|
+
server_url, _instance_data, tls_needed = _validate_server(
|
|
296
|
+
server_url, non_interactive=non_interactive, input_func=input_func
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
# Step 3: CA certificate
|
|
300
|
+
print()
|
|
301
|
+
ca_file_value = _resolve_ca(
|
|
302
|
+
server_url,
|
|
303
|
+
existing_ca=raw.get("ca_file") or None,
|
|
304
|
+
tls_needed=tls_needed,
|
|
305
|
+
non_interactive=non_interactive,
|
|
306
|
+
input_func=input_func,
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
# Step 4: federation key
|
|
310
|
+
print()
|
|
311
|
+
key_file_raw = raw.get("key_file") or config_mod.DEFAULT_KEY_FILE
|
|
312
|
+
_resolve_federation_key(
|
|
313
|
+
config_mod._expand(key_file_raw),
|
|
314
|
+
non_interactive=non_interactive,
|
|
315
|
+
input_func=input_func,
|
|
316
|
+
getpass_func=getpass_func,
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
# Step 5: write config (merge -- preserves sort/poll_interval/focus_app/etc.)
|
|
320
|
+
patch: dict = {"server_url": server_url, "key_file": key_file_raw}
|
|
321
|
+
if ca_file_value:
|
|
322
|
+
patch["ca_file"] = ca_file_value
|
|
323
|
+
config_mod.patch_raw_config(patch, config_path)
|
|
324
|
+
resolved_path = config_mod._resolve_config_path(config_path)
|
|
325
|
+
print(f"\nConfig saved: {resolved_path}")
|
|
326
|
+
|
|
327
|
+
# Step 6: verify
|
|
328
|
+
print("\nVerifying...")
|
|
329
|
+
final_raw = config_mod.load_raw_config(config_path)
|
|
330
|
+
ca_path = (
|
|
331
|
+
config_mod._expand(final_raw["ca_file"]) if final_raw.get("ca_file") else None
|
|
332
|
+
)
|
|
333
|
+
status, message = cli_mod.check_server_reachable(server_url, ca_path)
|
|
334
|
+
cli_mod.print_check(status, message)
|
|
335
|
+
status, message = cli_mod.check_deck_detected(config_path)
|
|
336
|
+
cli_mod.print_check(status, message)
|
|
337
|
+
status, message = cli_mod.check_hid_openable()
|
|
338
|
+
cli_mod.print_check(status, message)
|
|
339
|
+
|
|
340
|
+
# Step 7: next steps
|
|
341
|
+
if sys.platform not in ("darwin", "win32") and not service_mod.udev_rule_exists():
|
|
342
|
+
print()
|
|
343
|
+
print(service_mod._UDEV_REMEDIATION)
|
|
344
|
+
|
|
345
|
+
if not non_interactive and _confirm(
|
|
346
|
+
"\nRun `muxplex-deck service install` now?",
|
|
347
|
+
default=False,
|
|
348
|
+
input_func=input_func,
|
|
349
|
+
):
|
|
350
|
+
service_mod.service_install()
|
|
351
|
+
|
|
352
|
+
print("\nYou're set up -- run `muxplex-deck` or `muxplex-deck service install`.\n")
|
|
353
|
+
return 0
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def run_init(
|
|
357
|
+
config_path: str | None,
|
|
358
|
+
server_url_arg: str | None = None,
|
|
359
|
+
*,
|
|
360
|
+
non_interactive: bool = False,
|
|
361
|
+
input_func: Callable[[str], str] = input,
|
|
362
|
+
getpass_func: Callable[[str], str] = getpass.getpass,
|
|
363
|
+
) -> int:
|
|
364
|
+
"""Run the interactive setup wizard. Idempotent and safe to re-run.
|
|
365
|
+
|
|
366
|
+
Returns a process exit code (0 success, 1 a required input/validation
|
|
367
|
+
failed, 130 aborted via Ctrl-C/EOF before any config was written).
|
|
368
|
+
"""
|
|
369
|
+
try:
|
|
370
|
+
return _run_init_impl(
|
|
371
|
+
config_path,
|
|
372
|
+
server_url_arg,
|
|
373
|
+
non_interactive=non_interactive,
|
|
374
|
+
input_func=input_func,
|
|
375
|
+
getpass_func=getpass_func,
|
|
376
|
+
)
|
|
377
|
+
except _InitError as exc:
|
|
378
|
+
print(f"\nERROR: {exc}", file=sys.stderr)
|
|
379
|
+
return 1
|
|
380
|
+
except (KeyboardInterrupt, EOFError):
|
|
381
|
+
print("\n\nAborted -- no changes made.", file=sys.stderr)
|
|
382
|
+
return 130
|