mmrelay 1.2.6__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.
Files changed (50) hide show
  1. mmrelay/__init__.py +5 -0
  2. mmrelay/__main__.py +29 -0
  3. mmrelay/cli.py +2013 -0
  4. mmrelay/cli_utils.py +746 -0
  5. mmrelay/config.py +956 -0
  6. mmrelay/constants/__init__.py +65 -0
  7. mmrelay/constants/app.py +29 -0
  8. mmrelay/constants/config.py +78 -0
  9. mmrelay/constants/database.py +22 -0
  10. mmrelay/constants/formats.py +20 -0
  11. mmrelay/constants/messages.py +45 -0
  12. mmrelay/constants/network.py +45 -0
  13. mmrelay/constants/plugins.py +42 -0
  14. mmrelay/constants/queue.py +20 -0
  15. mmrelay/db_runtime.py +269 -0
  16. mmrelay/db_utils.py +1017 -0
  17. mmrelay/e2ee_utils.py +400 -0
  18. mmrelay/log_utils.py +274 -0
  19. mmrelay/main.py +439 -0
  20. mmrelay/matrix_utils.py +3091 -0
  21. mmrelay/meshtastic_utils.py +1245 -0
  22. mmrelay/message_queue.py +647 -0
  23. mmrelay/plugin_loader.py +1933 -0
  24. mmrelay/plugins/__init__.py +3 -0
  25. mmrelay/plugins/base_plugin.py +638 -0
  26. mmrelay/plugins/debug_plugin.py +30 -0
  27. mmrelay/plugins/drop_plugin.py +127 -0
  28. mmrelay/plugins/health_plugin.py +64 -0
  29. mmrelay/plugins/help_plugin.py +79 -0
  30. mmrelay/plugins/map_plugin.py +353 -0
  31. mmrelay/plugins/mesh_relay_plugin.py +222 -0
  32. mmrelay/plugins/nodes_plugin.py +92 -0
  33. mmrelay/plugins/ping_plugin.py +128 -0
  34. mmrelay/plugins/telemetry_plugin.py +179 -0
  35. mmrelay/plugins/weather_plugin.py +312 -0
  36. mmrelay/runtime_utils.py +35 -0
  37. mmrelay/setup_utils.py +828 -0
  38. mmrelay/tools/__init__.py +27 -0
  39. mmrelay/tools/mmrelay.service +19 -0
  40. mmrelay/tools/sample-docker-compose-prebuilt.yaml +30 -0
  41. mmrelay/tools/sample-docker-compose.yaml +30 -0
  42. mmrelay/tools/sample.env +10 -0
  43. mmrelay/tools/sample_config.yaml +120 -0
  44. mmrelay/windows_utils.py +346 -0
  45. mmrelay-1.2.6.dist-info/METADATA +145 -0
  46. mmrelay-1.2.6.dist-info/RECORD +50 -0
  47. mmrelay-1.2.6.dist-info/WHEEL +5 -0
  48. mmrelay-1.2.6.dist-info/entry_points.txt +2 -0
  49. mmrelay-1.2.6.dist-info/licenses/LICENSE +675 -0
  50. mmrelay-1.2.6.dist-info/top_level.txt +1 -0
mmrelay/setup_utils.py ADDED
@@ -0,0 +1,828 @@
1
+ """
2
+ Setup utilities for MMRelay.
3
+
4
+ This module provides simple functions for managing the systemd user service
5
+ and generating configuration files.
6
+ """
7
+
8
+ import importlib.resources
9
+
10
+ # Import version from package
11
+ import os
12
+ import re
13
+ import shutil
14
+ import subprocess
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ from mmrelay.constants.database import PROGRESS_COMPLETE, PROGRESS_TOTAL_STEPS
19
+ from mmrelay.tools import get_service_template_path
20
+
21
+ # Resolve systemctl path dynamically with fallback
22
+ SYSTEMCTL = shutil.which("systemctl") or "/usr/bin/systemctl"
23
+
24
+
25
+ def _quote_if_needed(path: str) -> str:
26
+ """Quote executable paths that contain spaces for systemd compatibility."""
27
+ return f'"{path}"' if " " in path else path
28
+
29
+
30
+ def get_resolved_exec_cmd() -> str:
31
+ """
32
+ Return the resolved command used to invoke MMRelay.
33
+
34
+ Prefers an mmrelay executable found on PATH; if found returns its filesystem path (quoted if it contains spaces). If not found, returns an invocation that runs the current Python interpreter with `-m mmrelay` (the interpreter path will be quoted if it contains spaces). The returned string is suitable for use as an ExecStart value in a systemd unit.
35
+ """
36
+ mmrelay_path = shutil.which("mmrelay")
37
+ if mmrelay_path:
38
+ return _quote_if_needed(mmrelay_path)
39
+ py = _quote_if_needed(sys.executable)
40
+ return f"{py} -m mmrelay"
41
+
42
+
43
+ def get_executable_path():
44
+ """
45
+ Return the resolved command to invoke the mmrelay executable with user feedback.
46
+
47
+ This is a wrapper around get_resolved_exec_cmd() that adds print statements
48
+ for user feedback during setup operations.
49
+
50
+ Returns:
51
+ str: Either the filesystem path to the `mmrelay` executable or a Python module
52
+ invocation string using the current interpreter.
53
+ """
54
+ resolved_cmd = get_resolved_exec_cmd()
55
+ if " -m mmrelay" in resolved_cmd:
56
+ print(
57
+ "Warning: Could not find mmrelay executable in PATH. Using current Python interpreter.",
58
+ file=sys.stderr,
59
+ )
60
+ else:
61
+ print(f"Found mmrelay executable at: {resolved_cmd}")
62
+ return resolved_cmd
63
+
64
+
65
+ def get_resolved_exec_start(
66
+ args_suffix: str = " --config %h/.mmrelay/config.yaml --logfile %h/.mmrelay/logs/mmrelay.log",
67
+ ) -> str:
68
+ """
69
+ Return a complete systemd `ExecStart=` line for the mmrelay service.
70
+
71
+ Parameters:
72
+ args_suffix (str): Command-line arguments appended to the resolved mmrelay command.
73
+ Defaults to `" --config %h/.mmrelay/config.yaml --logfile %h/.mmrelay/logs/mmrelay.log"`.
74
+ Typical values may include systemd specifiers like `%h` for the user home directory.
75
+
76
+ Returns:
77
+ str: A single-line string beginning with `ExecStart=` containing the resolved executable
78
+ invocation followed by the provided argument suffix.
79
+ """
80
+ return f"ExecStart={get_resolved_exec_cmd()}{args_suffix}"
81
+
82
+
83
+ def get_user_service_path():
84
+ """Get the path to the user service file."""
85
+ service_dir = Path.home() / ".config" / "systemd" / "user"
86
+ return service_dir / "mmrelay.service"
87
+
88
+
89
+ def service_exists():
90
+ """Check if the service file exists."""
91
+ return get_user_service_path().exists()
92
+
93
+
94
+ def print_service_commands():
95
+ """Print the commands for controlling the systemd user service."""
96
+ print(" systemctl --user start mmrelay.service # Start the service")
97
+ print(" systemctl --user stop mmrelay.service # Stop the service")
98
+ print(" systemctl --user restart mmrelay.service # Restart the service")
99
+ print(" systemctl --user status mmrelay.service # Check service status")
100
+
101
+
102
+ def wait_for_service_start():
103
+ """
104
+ Wait up to ~10 seconds for the user mmrelay systemd service to become active.
105
+
106
+ Blocks while periodically checking is_service_active(). When running interactively (not as a service) a Rich spinner and elapsed-time display are shown; when running as a service the function performs the same timed checks without UI. The wait may finish early if the service becomes active (checks begin allowing early exit after ~5 seconds). This function does not return a value.
107
+ """
108
+ import time
109
+
110
+ from mmrelay.runtime_utils import is_running_as_service
111
+
112
+ running_as_service = is_running_as_service()
113
+ if not running_as_service:
114
+ try:
115
+ from rich.progress import (
116
+ Progress,
117
+ SpinnerColumn,
118
+ TextColumn,
119
+ TimeElapsedColumn,
120
+ )
121
+ except Exception:
122
+ running_as_service = True
123
+
124
+ # Create a Rich progress display with spinner and elapsed time
125
+ if not running_as_service:
126
+ with Progress(
127
+ SpinnerColumn(),
128
+ TextColumn("[bold green]Starting mmrelay service..."),
129
+ TimeElapsedColumn(),
130
+ transient=True,
131
+ ) as progress:
132
+ # Add a task that will run for approximately 10 seconds
133
+ task = progress.add_task("Starting", total=PROGRESS_TOTAL_STEPS)
134
+
135
+ # Update progress over ~10 seconds
136
+ step = max(1, PROGRESS_TOTAL_STEPS // 10)
137
+ for i in range(10):
138
+ time.sleep(1)
139
+ progress.update(
140
+ task, completed=min(PROGRESS_TOTAL_STEPS, step * (i + 1))
141
+ )
142
+
143
+ # Check if service is active after 5 seconds to potentially finish early
144
+ if i >= 5 and is_service_active():
145
+ progress.update(task, completed=PROGRESS_COMPLETE)
146
+ break
147
+ else:
148
+ # Simple fallback when running as service
149
+ for i in range(10):
150
+ time.sleep(1)
151
+ if i >= 5 and is_service_active():
152
+ break
153
+
154
+
155
+ def read_service_file():
156
+ """
157
+ Read and return the contents of the user's mmrelay systemd service file.
158
+
159
+ Returns:
160
+ str | None: The file contents decoded as UTF-8 if the service file exists, otherwise None.
161
+ """
162
+ service_path = get_user_service_path()
163
+ if service_path.exists():
164
+ return service_path.read_text(encoding="utf-8")
165
+ return None
166
+
167
+
168
+ def get_template_service_path():
169
+ """
170
+ Locate the mmrelay systemd service template on disk.
171
+
172
+ Searches a deterministic list of candidate locations (package directory, package/tools,
173
+ sys.prefix share paths, user local share (~/.local/share), parent-directory development
174
+ paths, and ./tools) and returns the first existing path.
175
+
176
+ If no template is found, the function prints a warning to stderr listing all
177
+ attempted locations and returns None.
178
+
179
+ Returns:
180
+ str | None: Path to the found mmrelay.service template, or None if not found.
181
+ """
182
+ # Try to find the service template file
183
+ package_dir = os.path.dirname(__file__)
184
+
185
+ # Try to find the service template file in various locations
186
+ template_paths = [
187
+ # Check in the package directory (where it should be after installation)
188
+ os.path.join(package_dir, "mmrelay.service"),
189
+ # Check in a tools subdirectory of the package
190
+ os.path.join(package_dir, "tools", "mmrelay.service"),
191
+ # Check in the data files location (where it should be after installation)
192
+ os.path.join(sys.prefix, "share", "mmrelay", "mmrelay.service"),
193
+ os.path.join(sys.prefix, "share", "mmrelay", "tools", "mmrelay.service"),
194
+ # Check in the user site-packages location
195
+ os.path.join(
196
+ os.path.expanduser("~"), ".local", "share", "mmrelay", "mmrelay.service"
197
+ ),
198
+ os.path.join(
199
+ os.path.expanduser("~"),
200
+ ".local",
201
+ "share",
202
+ "mmrelay",
203
+ "tools",
204
+ "mmrelay.service",
205
+ ),
206
+ # Check one level up from the package directory
207
+ os.path.join(os.path.dirname(package_dir), "tools", "mmrelay.service"),
208
+ # Check two levels up from the package directory (for development)
209
+ os.path.join(
210
+ os.path.dirname(os.path.dirname(package_dir)), "tools", "mmrelay.service"
211
+ ),
212
+ # Check in the repository root (for development)
213
+ os.path.join(
214
+ os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
215
+ "tools",
216
+ "mmrelay.service",
217
+ ),
218
+ # Check in the current directory (fallback)
219
+ os.path.join(os.getcwd(), "tools", "mmrelay.service"),
220
+ ]
221
+
222
+ # Try each path until we find one that exists
223
+ for path in template_paths:
224
+ if os.path.exists(path):
225
+ return path
226
+
227
+ # If we get here, we couldn't find the template
228
+ # Warning output to help diagnose issues
229
+ print(
230
+ "Warning: Could not find mmrelay.service in any of these locations:",
231
+ file=sys.stderr,
232
+ )
233
+ for path in template_paths:
234
+ print(f" - {path}", file=sys.stderr)
235
+
236
+ # If we get here, we couldn't find the template
237
+ return None
238
+
239
+
240
+ def get_template_service_content():
241
+ """
242
+ Return the systemd service unit content to install for the user-level mmrelay service.
243
+
244
+ Attempts to load a template in this order:
245
+ 1. The external path returned by get_service_template_path() (UTF-8).
246
+ 2. The embedded package resource "mmrelay.service" from mmrelay.tools via importlib.resources.
247
+ 3. A second filesystem probe using get_template_service_path() (UTF-8).
248
+
249
+ If none of the above can be read, returns a built-in default service unit that includes a resolved ExecStart (from get_resolved_exec_start()), sensible Environment settings (including PYTHONUNBUFFERED and a PATH containing common user-local locations), and standard Unit/Service/Install sections.
250
+
251
+ Returns:
252
+ str: Complete service file content to write. Read/access errors are reported to stderr.
253
+ """
254
+ # Use the helper function to get the service template path
255
+ template_path = get_service_template_path()
256
+
257
+ if template_path and os.path.exists(template_path):
258
+ # Read the template from file
259
+ try:
260
+ with open(template_path, "r", encoding="utf-8") as f:
261
+ service_template = f.read()
262
+ return service_template
263
+ except (OSError, IOError, UnicodeDecodeError) as e:
264
+ print(f"Error reading service template file: {e}", file=sys.stderr)
265
+
266
+ # If the helper function failed, try using importlib.resources directly
267
+ try:
268
+ service_template = (
269
+ importlib.resources.files("mmrelay.tools")
270
+ .joinpath("mmrelay.service")
271
+ .read_text(encoding="utf-8")
272
+ )
273
+ return service_template
274
+ except (FileNotFoundError, ImportError, OSError, UnicodeDecodeError) as e:
275
+ print(
276
+ f"Error accessing mmrelay.service via importlib.resources: {e}",
277
+ file=sys.stderr,
278
+ )
279
+
280
+ # Fall back to the file path method
281
+ template_path = get_template_service_path()
282
+ if template_path:
283
+ # Read the template from file
284
+ try:
285
+ with open(template_path, "r", encoding="utf-8") as f:
286
+ service_template = f.read()
287
+ return service_template
288
+ except (OSError, IOError, UnicodeDecodeError) as e:
289
+ print(f"Error reading service template file: {e}", file=sys.stderr)
290
+
291
+ # If we couldn't find or read the template file, use a default template
292
+ print("Using default service template", file=sys.stderr)
293
+ resolved_exec_start = get_resolved_exec_start()
294
+ return f"""[Unit]
295
+ Description=MMRelay - Meshtastic <=> Matrix Relay
296
+ After=network-online.target
297
+ Wants=network-online.target
298
+
299
+ [Service]
300
+ Type=simple
301
+ # The mmrelay binary can be installed via pipx or pip
302
+ {resolved_exec_start}
303
+ WorkingDirectory=%h/.mmrelay
304
+ Restart=on-failure
305
+ RestartSec=10
306
+ Environment=PYTHONUNBUFFERED=1
307
+ Environment=LANG=C.UTF-8
308
+ # Ensure both pipx and pip environments are properly loaded
309
+ Environment=PATH=%h/.local/bin:%h/.local/pipx/venvs/mmrelay/bin:/usr/local/bin:/usr/bin:/bin
310
+
311
+ [Install]
312
+ WantedBy=default.target
313
+ """
314
+
315
+
316
+ def is_service_enabled():
317
+ """
318
+ Return whether the user systemd service 'mmrelay.service' is enabled to start at login.
319
+
320
+ Uses the resolved SYSTEMCTL command to run `SYSTEMCTL --user is-enabled mmrelay.service`. Returns True only if the command exits successfully and its stdout equals "enabled"; returns False on any error or non-enabled state.
321
+ """
322
+ try:
323
+ result = subprocess.run(
324
+ [SYSTEMCTL, "--user", "is-enabled", "mmrelay.service"],
325
+ check=False, # Don't raise an exception if the service is not enabled
326
+ capture_output=True,
327
+ text=True,
328
+ )
329
+ return result.returncode == 0 and result.stdout.strip() == "enabled"
330
+ except (OSError, subprocess.SubprocessError) as e:
331
+ print(f"Warning: Failed to check service enabled status: {e}", file=sys.stderr)
332
+ return False
333
+
334
+
335
+ def is_service_active():
336
+ """
337
+ Return True if the user systemd unit 'mmrelay.service' is currently active (running).
338
+
339
+ Checks the service state by invoking the resolved systemctl executable with
340
+ '--user is-active mmrelay.service'. On command failure or exceptions (e.g.
341
+ OSError, subprocess errors) the function prints a warning to stderr and returns False.
342
+
343
+ Returns:
344
+ bool: True when the service is active; False otherwise or on error.
345
+ """
346
+ try:
347
+ result = subprocess.run(
348
+ [SYSTEMCTL, "--user", "is-active", "mmrelay.service"],
349
+ check=False, # Don't raise an exception if the service is not active
350
+ capture_output=True,
351
+ text=True,
352
+ )
353
+ return result.returncode == 0 and result.stdout.strip() == "active"
354
+ except (OSError, subprocess.SubprocessError) as e:
355
+ print(f"Warning: Failed to check service active status: {e}", file=sys.stderr)
356
+ return False
357
+
358
+
359
+ def create_service_file():
360
+ """
361
+ Create or update the per-user systemd unit file for MMRelay.
362
+
363
+ Ensures the user systemd directory (~/.config/systemd/user) and the MMRelay logs directory (~/.mmrelay/logs) exist, obtains a service unit template using the module's template-loading fallbacks, substitutes known placeholders (working directory, packaged launcher, and config path), normalizes the Unit's ExecStart to the resolved MMRelay invocation (an mmrelay executable on PATH or a Python `-m mmrelay` fallback) while preserving any trailing arguments, and writes the resulting unit to ~/.config/systemd/user/mmrelay.service.
364
+
365
+ Returns:
366
+ bool: True if the service file was written successfully; False if a template could not be obtained or writing the file failed.
367
+ """
368
+ # Get executable paths once to avoid duplicate calls and output
369
+ executable_path = get_executable_path()
370
+
371
+ # Create service directory if it doesn't exist
372
+ service_dir = get_user_service_path().parent
373
+ service_dir.mkdir(parents=True, exist_ok=True)
374
+
375
+ # Create logs directory if it doesn't exist
376
+ logs_dir = Path.home() / ".mmrelay" / "logs"
377
+ logs_dir.mkdir(parents=True, exist_ok=True)
378
+
379
+ # Get the template service content
380
+ service_template = get_template_service_content()
381
+ if not service_template:
382
+ print("Error: Could not find service template file", file=sys.stderr)
383
+ return False
384
+
385
+ # Replace placeholders with actual values
386
+ service_content = (
387
+ service_template.replace(
388
+ "WorkingDirectory=%h/meshtastic-matrix-relay",
389
+ "# WorkingDirectory is not needed for installed package",
390
+ )
391
+ .replace(
392
+ "%h/meshtastic-matrix-relay/.pyenv/bin/python %h/meshtastic-matrix-relay/main.py",
393
+ executable_path,
394
+ )
395
+ .replace(
396
+ "--config %h/.mmrelay/config/config.yaml",
397
+ "--config %h/.mmrelay/config.yaml",
398
+ )
399
+ )
400
+
401
+ # Normalize ExecStart: replace any mmrelay launcher with resolved command, preserving args
402
+ pattern = re.compile(
403
+ r'(?m)^\s*(ExecStart=)"?(?:'
404
+ r"/usr/bin/env\s+mmrelay"
405
+ r"|(?:\S*?[\\/])?mmrelay\b"
406
+ r"|\S*\bpython(?:\d+(?:\.\d+)*)?(?:\.exe)?\b\s+-m\s+mmrelay"
407
+ r')"?(\s.*)?$'
408
+ )
409
+ service_content = pattern.sub(
410
+ lambda m: f"{m.group(1)}{executable_path}{m.group(2) or ''}",
411
+ service_content,
412
+ )
413
+
414
+ # Write service file
415
+ try:
416
+ get_user_service_path().write_text(service_content, encoding="utf-8")
417
+ print(f"Service file created at {get_user_service_path()}")
418
+ return True
419
+ except (IOError, OSError) as e:
420
+ print(f"Error creating service file: {e}", file=sys.stderr)
421
+ return False
422
+
423
+
424
+ def reload_daemon():
425
+ """
426
+ Reload the current user's systemd daemon to apply unit file changes.
427
+
428
+ Attempts to run the resolved `SYSTEMCTL` command with `--user daemon-reload`. Returns True if the subprocess exits successfully; returns False on failure (subprocess error or OSError). Side effects: prints a success message to stdout or an error message to stderr.
429
+ """
430
+ try:
431
+ # Using resolved systemctl path
432
+ subprocess.run([SYSTEMCTL, "--user", "daemon-reload"], check=True)
433
+ print("Systemd user daemon reloaded")
434
+ return True
435
+ except subprocess.CalledProcessError as e:
436
+ print(f"Error reloading systemd daemon: {e}", file=sys.stderr)
437
+ return False
438
+ except OSError as e:
439
+ print(f"Error: {e}", file=sys.stderr)
440
+ return False
441
+
442
+
443
+ def service_needs_update():
444
+ """
445
+ Return whether the user systemd unit for mmrelay should be updated.
446
+
447
+ Performs checks in this order and returns (needs_update, reason):
448
+ - No installed unit file => update required.
449
+ - Installed unit must contain an ExecStart= line that invokes mmrelay via an acceptable form:
450
+ - an mmrelay executable found on PATH,
451
+ - "/usr/bin/env mmrelay",
452
+ - or the current Python interpreter with "-m mmrelay".
453
+ If none match, an update is recommended.
454
+ - Unit Environment= PATH lines must include common user bin locations (e.g. "%h/.local/pipx/venvs/mmrelay/bin" or "%h/.local/bin"); if missing, an update is recommended.
455
+ - If a template service file is available on disk, its modification time is compared to the installed unit; if the template is newer, an update is recommended.
456
+
457
+ Returns:
458
+ tuple: (needs_update: bool, reason: str) — True when an update is recommended or required; reason explains the decision or why the check failed (e.g., missing ExecStart, missing PATH entries, stat error).
459
+ """
460
+ # Check if service already exists
461
+ existing_service = read_service_file()
462
+ if not existing_service:
463
+ return True, "No existing service file found"
464
+
465
+ # Get the template service path
466
+ template_path = get_template_service_path()
467
+
468
+ # Get the acceptable executable paths
469
+ mmrelay_path = shutil.which("mmrelay")
470
+ acceptable_execs = [
471
+ f"{_quote_if_needed(sys.executable)} -m mmrelay",
472
+ "/usr/bin/env mmrelay",
473
+ ]
474
+ if mmrelay_path:
475
+ acceptable_execs.append(_quote_if_needed(mmrelay_path))
476
+
477
+ # Check if the ExecStart line in the existing service file contains an acceptable executable form
478
+ exec_start_line = next(
479
+ (
480
+ line
481
+ for line in existing_service.splitlines()
482
+ if line.strip().startswith("ExecStart=")
483
+ ),
484
+ None,
485
+ )
486
+
487
+ if not exec_start_line:
488
+ return True, "Service file is missing ExecStart line"
489
+
490
+ if not any(exec_str in exec_start_line for exec_str in acceptable_execs):
491
+ return (
492
+ True,
493
+ "Service file does not use an acceptable executable "
494
+ f"({ ' or '.join(acceptable_execs) }).",
495
+ )
496
+
497
+ # Check if the PATH environment includes common user-bin locations
498
+ # Look specifically in Environment lines, not the entire file
499
+ environment_lines = [
500
+ line
501
+ for line in existing_service.splitlines()
502
+ if line.strip().startswith("Environment=")
503
+ ]
504
+ path_in_environment = any(
505
+ "%h/.local/pipx/venvs/mmrelay/bin" in line or "%h/.local/bin" in line
506
+ for line in environment_lines
507
+ )
508
+ if not path_in_environment:
509
+ return True, "Service PATH does not include common user-bin locations"
510
+
511
+ # Check if the service file has been modified recently
512
+ service_path = get_user_service_path()
513
+ if template_path and os.path.exists(template_path) and os.path.exists(service_path):
514
+ try:
515
+ template_mtime = os.path.getmtime(template_path)
516
+ service_mtime = os.path.getmtime(service_path)
517
+ except OSError:
518
+ return True, "Unable to stat template or service file"
519
+ if template_mtime > service_mtime:
520
+ return True, "Template service file is newer than installed service file"
521
+
522
+ return False, "Service file is up to date"
523
+
524
+
525
+ def check_loginctl_available():
526
+ """
527
+ Return True if `loginctl` is available and runnable on PATH.
528
+
529
+ This locates `loginctl` using the PATH (shutil.which) and attempts to run `loginctl --version`.
530
+ Returns False if the executable is not found or if invoking it fails/returns a non-zero exit code.
531
+ """
532
+ path = shutil.which("loginctl")
533
+ if not path:
534
+ return False
535
+ try:
536
+ result = subprocess.run(
537
+ [path, "--version"], check=False, capture_output=True, text=True
538
+ )
539
+ return result.returncode == 0
540
+ except (OSError, subprocess.SubprocessError) as e:
541
+ print(f"Warning: Failed to check loginctl availability: {e}", file=sys.stderr)
542
+ return False
543
+
544
+
545
+ def check_lingering_enabled():
546
+ """
547
+ Return whether systemd user "lingering" is enabled for the current user.
548
+
549
+ Checks for a usable `loginctl` executable, queries `loginctl show-user <user> --property=Linger`
550
+ (using the environment variable USER or USERNAME to determine the account), and returns True
551
+ only if the command succeeds and reports `Linger=yes`. If `loginctl` is not found, the command
552
+ fails, or an unexpected error occurs, the function returns False.
553
+ """
554
+ try:
555
+ import getpass
556
+
557
+ username = (
558
+ os.environ.get("USER") or os.environ.get("USERNAME") or getpass.getuser()
559
+ )
560
+ if not username:
561
+ print(
562
+ "Error checking lingering status: could not determine current user",
563
+ file=sys.stderr,
564
+ )
565
+ return False
566
+ loginctl = shutil.which("loginctl")
567
+ if not loginctl:
568
+ return False
569
+ result = subprocess.run(
570
+ [loginctl, "show-user", username, "--property=Linger"],
571
+ check=False,
572
+ capture_output=True,
573
+ text=True,
574
+ )
575
+ return result.returncode == 0 and "Linger=yes" in result.stdout
576
+ except (OSError, subprocess.SubprocessError, KeyError, RuntimeError) as e:
577
+ print(f"Error checking lingering status: {e}", file=sys.stderr)
578
+ return False
579
+
580
+
581
+ def enable_lingering():
582
+ """
583
+ Enable systemd "lingering" for the current user by running `sudo loginctl enable-linger <user>`.
584
+
585
+ Determines the username from environment variables or getpass.getuser(), invokes the privileged `loginctl` command to enable lingering, and returns True if the command exits successfully. On failure (non-zero exit, missing username, or subprocess/OSError), returns False and prints an error message to stderr.
586
+ """
587
+ try:
588
+ import getpass
589
+
590
+ username = (
591
+ os.environ.get("USER") or os.environ.get("USERNAME") or getpass.getuser()
592
+ )
593
+ if not username:
594
+ print(
595
+ "Error enabling lingering: could not determine current user",
596
+ file=sys.stderr,
597
+ )
598
+ return False
599
+ print(f"Enabling lingering for user {username}...")
600
+ result = subprocess.run(
601
+ ["sudo", "loginctl", "enable-linger", username],
602
+ check=False,
603
+ capture_output=True,
604
+ text=True,
605
+ )
606
+ if result.returncode == 0:
607
+ print("Lingering enabled successfully")
608
+ return True
609
+ else:
610
+ print(f"Error enabling lingering: {result.stderr}", file=sys.stderr)
611
+ return False
612
+ except (OSError, subprocess.SubprocessError) as e:
613
+ print(f"Error enabling lingering: {e}", file=sys.stderr)
614
+ return False
615
+
616
+
617
+ def install_service():
618
+ """
619
+ Install or update the MMRelay systemd user service, guiding the user through creation, updating, enabling, and starting the service as needed.
620
+
621
+ Prompts the user for confirmation before updating an existing service file, enabling user lingering, enabling the service to start at boot, and starting or restarting the service. Handles user interruptions gracefully and prints a summary of the service status and management commands upon completion.
622
+
623
+ Returns:
624
+ bool: True if the installation or update process completes successfully, False otherwise.
625
+ """
626
+ # Check if service already exists
627
+ existing_service = read_service_file()
628
+ service_path = get_user_service_path()
629
+
630
+ # Check if the service needs to be updated
631
+ update_needed, reason = service_needs_update()
632
+
633
+ # Check if the service is already installed and if it needs updating
634
+ if existing_service:
635
+ print(f"A service file already exists at {service_path}")
636
+
637
+ if update_needed:
638
+ print(f"The service file needs to be updated: {reason}")
639
+ try:
640
+ user_input = input("Do you want to update the service file? (y/n): ")
641
+ if not user_input.lower().startswith("y"):
642
+ print("Service update cancelled.")
643
+ print_service_commands()
644
+ return True
645
+ except (EOFError, KeyboardInterrupt):
646
+ print("\nInput cancelled. Proceeding with default behavior.")
647
+ print("Service update cancelled.")
648
+ print_service_commands()
649
+ return True
650
+ else:
651
+ print(f"No update needed for the service file: {reason}")
652
+ else:
653
+ print(f"No service file found at {service_path}")
654
+ print("A new service file will be created.")
655
+
656
+ # Create or update service file if needed
657
+ if not existing_service or update_needed:
658
+ if not create_service_file():
659
+ return False
660
+
661
+ # Reload daemon (continue even if this fails)
662
+ if not reload_daemon():
663
+ print(
664
+ "Warning: Failed to reload systemd daemon. You may need to run 'systemctl --user daemon-reload' manually.",
665
+ file=sys.stderr,
666
+ )
667
+
668
+ if existing_service:
669
+ print("Service file updated successfully")
670
+ else:
671
+ print("Service file created successfully")
672
+
673
+ # We don't need to validate the config here as it will be validated when the service starts
674
+
675
+ # Check if loginctl is available
676
+ loginctl_available = check_loginctl_available()
677
+ if loginctl_available:
678
+ # Check if user lingering is enabled
679
+ lingering_enabled = check_lingering_enabled()
680
+ if not lingering_enabled:
681
+ print(
682
+ "\nUser lingering is not enabled. This is required for the service to start automatically at boot."
683
+ )
684
+ print(
685
+ "Lingering allows user services to run even when you're not logged in."
686
+ )
687
+ try:
688
+ user_input = input(
689
+ "Do you want to enable lingering for your user? (requires sudo) (y/n): "
690
+ )
691
+ should_enable_lingering = user_input.lower().startswith("y")
692
+ except (EOFError, KeyboardInterrupt):
693
+ print("\nInput cancelled. Skipping lingering setup.")
694
+ should_enable_lingering = False
695
+
696
+ if should_enable_lingering:
697
+ enable_lingering()
698
+
699
+ # Check if the service is already enabled
700
+ service_enabled = is_service_enabled()
701
+ if service_enabled:
702
+ print("The service is already enabled to start at boot.")
703
+ else:
704
+ print("The service is not currently enabled to start at boot.")
705
+ try:
706
+ user_input = input(
707
+ "Do you want to enable the service to start at boot? (y/n): "
708
+ )
709
+ enable_service = user_input.lower().startswith("y")
710
+ except (EOFError, KeyboardInterrupt):
711
+ print("\nInput cancelled. Skipping service enable.")
712
+ enable_service = False
713
+
714
+ if enable_service:
715
+ try:
716
+ subprocess.run(
717
+ [SYSTEMCTL, "--user", "enable", "mmrelay.service"],
718
+ check=True,
719
+ )
720
+ print("Service enabled successfully")
721
+ service_enabled = True
722
+ except subprocess.CalledProcessError as e:
723
+ print(f"Error enabling service: {e}", file=sys.stderr)
724
+ except OSError as e:
725
+ print(f"Error: {e}", file=sys.stderr)
726
+
727
+ # Check if the service is already running
728
+ service_active = is_service_active()
729
+ if service_active:
730
+ print("The service is already running.")
731
+ try:
732
+ user_input = input("Do you want to restart the service? (y/n): ")
733
+ restart_service = user_input.lower().startswith("y")
734
+ except (EOFError, KeyboardInterrupt):
735
+ print("\nInput cancelled. Skipping service restart.")
736
+ restart_service = False
737
+
738
+ if restart_service:
739
+ try:
740
+ subprocess.run(
741
+ [SYSTEMCTL, "--user", "restart", "mmrelay.service"],
742
+ check=True,
743
+ )
744
+ print("Service restarted successfully")
745
+ # Wait for the service to restart
746
+ wait_for_service_start()
747
+ # Show service status
748
+ show_service_status()
749
+ except subprocess.CalledProcessError as e:
750
+ print(f"Error restarting service: {e}", file=sys.stderr)
751
+ except OSError as e:
752
+ print(f"Error: {e}", file=sys.stderr)
753
+ else:
754
+ print("The service is not currently running.")
755
+ try:
756
+ user_input = input("Do you want to start the service now? (y/n): ")
757
+ start_now = user_input.lower().startswith("y")
758
+ except (EOFError, KeyboardInterrupt):
759
+ print("\nInput cancelled. Skipping service start.")
760
+ start_now = False
761
+
762
+ if start_now:
763
+ if start_service():
764
+ # Wait for the service to start
765
+ wait_for_service_start()
766
+ # Show service status
767
+ show_service_status()
768
+ print("Service started successfully")
769
+ else:
770
+ print("\nWarning: Failed to start the service. Please check the logs.")
771
+
772
+ # Print a summary of the service status
773
+ print("\nService Status Summary:")
774
+ print(f" Service File: {service_path}")
775
+ print(f" Enabled at Boot: {'Yes' if service_enabled else 'No'}")
776
+ if loginctl_available:
777
+ print(f" User Lingering: {'Yes' if check_lingering_enabled() else 'No'}")
778
+ print(f" Currently Running: {'Yes' if is_service_active() else 'No'}")
779
+ print("\nService Management Commands:")
780
+ print_service_commands()
781
+
782
+ return True
783
+
784
+
785
+ def start_service():
786
+ """
787
+ Start the user-level systemd service for MMRelay.
788
+
789
+ Attempts to run `SYSTEMCTL --user start mmrelay.service`. Returns True if the command exits successfully.
790
+ On failure the function prints an error message to stderr and returns False.
791
+
792
+ Returns:
793
+ bool: True when the service was started successfully; False on error.
794
+ """
795
+ try:
796
+ subprocess.run([SYSTEMCTL, "--user", "start", "mmrelay.service"], check=True)
797
+ return True
798
+ except subprocess.CalledProcessError as e:
799
+ print(f"Error starting service: {e}", file=sys.stderr)
800
+ return False
801
+ except OSError as e:
802
+ print(f"Error: {e}", file=sys.stderr)
803
+ return False
804
+
805
+
806
+ def show_service_status():
807
+ """
808
+ Show the systemd user status for the mmrelay.service and print it to stdout.
809
+
810
+ Runs `SYSTEMCTL --user status mmrelay.service`, prints the command's stdout when successful,
811
+ and returns True. On failure (command error or OSError) prints an error message and returns False.
812
+ """
813
+ try:
814
+ result = subprocess.run(
815
+ [SYSTEMCTL, "--user", "status", "mmrelay.service"],
816
+ check=True,
817
+ capture_output=True,
818
+ text=True,
819
+ )
820
+ print("\nService Status:")
821
+ print(result.stdout)
822
+ return True
823
+ except subprocess.CalledProcessError as e:
824
+ print(f"Could not get service status: {e}", file=sys.stderr)
825
+ return False
826
+ except OSError as e:
827
+ print(f"Error: {e}", file=sys.stderr)
828
+ return False