mmrelay 1.2.0__py3-none-any.whl → 1.2.2__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.
Potentially problematic release.
This version of mmrelay might be problematic. Click here for more details.
- mmrelay/__init__.py +1 -1
- mmrelay/__main__.py +29 -0
- mmrelay/cli.py +735 -135
- mmrelay/cli_utils.py +59 -9
- mmrelay/config.py +198 -71
- mmrelay/constants/app.py +2 -2
- mmrelay/db_utils.py +73 -26
- mmrelay/e2ee_utils.py +6 -3
- mmrelay/log_utils.py +16 -5
- mmrelay/main.py +41 -38
- mmrelay/matrix_utils.py +1069 -293
- mmrelay/meshtastic_utils.py +350 -206
- mmrelay/message_queue.py +212 -62
- mmrelay/plugin_loader.py +634 -205
- mmrelay/plugins/mesh_relay_plugin.py +43 -38
- mmrelay/plugins/weather_plugin.py +11 -12
- mmrelay/runtime_utils.py +35 -0
- mmrelay/setup_utils.py +324 -129
- mmrelay/tools/mmrelay.service +2 -1
- mmrelay/tools/sample-docker-compose-prebuilt.yaml +11 -72
- mmrelay/tools/sample-docker-compose.yaml +12 -58
- mmrelay/tools/sample_config.yaml +14 -13
- mmrelay/windows_utils.py +349 -0
- {mmrelay-1.2.0.dist-info → mmrelay-1.2.2.dist-info}/METADATA +11 -11
- mmrelay-1.2.2.dist-info/RECORD +48 -0
- mmrelay-1.2.0.dist-info/RECORD +0 -45
- {mmrelay-1.2.0.dist-info → mmrelay-1.2.2.dist-info}/WHEEL +0 -0
- {mmrelay-1.2.0.dist-info → mmrelay-1.2.2.dist-info}/entry_points.txt +0 -0
- {mmrelay-1.2.0.dist-info → mmrelay-1.2.2.dist-info}/licenses/LICENSE +0 -0
- {mmrelay-1.2.0.dist-info → mmrelay-1.2.2.dist-info}/top_level.txt +0 -0
mmrelay/setup_utils.py
CHANGED
|
@@ -9,6 +9,7 @@ import importlib.resources
|
|
|
9
9
|
|
|
10
10
|
# Import version from package
|
|
11
11
|
import os
|
|
12
|
+
import re
|
|
12
13
|
import shutil
|
|
13
14
|
import subprocess
|
|
14
15
|
import sys
|
|
@@ -17,22 +18,66 @@ from pathlib import Path
|
|
|
17
18
|
from mmrelay.constants.database import PROGRESS_COMPLETE, PROGRESS_TOTAL_STEPS
|
|
18
19
|
from mmrelay.tools import get_service_template_path
|
|
19
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
|
|
20
28
|
|
|
21
|
-
def get_executable_path():
|
|
22
|
-
"""Get the full path to the mmrelay executable.
|
|
23
29
|
|
|
24
|
-
|
|
25
|
-
|
|
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.
|
|
26
35
|
"""
|
|
27
36
|
mmrelay_path = shutil.which("mmrelay")
|
|
28
37
|
if mmrelay_path:
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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:
|
|
32
56
|
print(
|
|
33
|
-
"Warning: Could not find mmrelay executable in PATH. Using current Python interpreter."
|
|
57
|
+
"Warning: Could not find mmrelay executable in PATH. Using current Python interpreter.",
|
|
58
|
+
file=sys.stderr,
|
|
34
59
|
)
|
|
35
|
-
|
|
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}"
|
|
36
81
|
|
|
37
82
|
|
|
38
83
|
def get_user_service_path():
|
|
@@ -56,48 +101,83 @@ def print_service_commands():
|
|
|
56
101
|
|
|
57
102
|
def wait_for_service_start():
|
|
58
103
|
"""
|
|
59
|
-
|
|
104
|
+
Wait up to ~10 seconds for the user mmrelay systemd service to become active.
|
|
60
105
|
|
|
61
|
-
|
|
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.
|
|
62
107
|
"""
|
|
63
108
|
import time
|
|
64
109
|
|
|
65
|
-
from
|
|
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
|
|
66
123
|
|
|
67
124
|
# Create a Rich progress display with spinner and elapsed time
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
78
149
|
for i in range(10):
|
|
79
150
|
time.sleep(1)
|
|
80
|
-
progress.update(task, completed=10 * (i + 1))
|
|
81
|
-
|
|
82
|
-
# Check if service is active after 5 seconds to potentially finish early
|
|
83
151
|
if i >= 5 and is_service_active():
|
|
84
|
-
progress.update(task, completed=PROGRESS_COMPLETE)
|
|
85
152
|
break
|
|
86
153
|
|
|
87
154
|
|
|
88
155
|
def read_service_file():
|
|
89
|
-
"""
|
|
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
|
+
"""
|
|
90
162
|
service_path = get_user_service_path()
|
|
91
163
|
if service_path.exists():
|
|
92
|
-
return service_path.read_text()
|
|
164
|
+
return service_path.read_text(encoding="utf-8")
|
|
93
165
|
return None
|
|
94
166
|
|
|
95
167
|
|
|
96
168
|
def get_template_service_path():
|
|
97
|
-
"""
|
|
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.
|
|
98
178
|
|
|
99
179
|
Returns:
|
|
100
|
-
str:
|
|
180
|
+
str | None: Path to the found mmrelay.service template, or None if not found.
|
|
101
181
|
"""
|
|
102
182
|
# Try to find the service template file
|
|
103
183
|
package_dir = os.path.dirname(__file__)
|
|
@@ -145,20 +225,31 @@ def get_template_service_path():
|
|
|
145
225
|
return path
|
|
146
226
|
|
|
147
227
|
# If we get here, we couldn't find the template
|
|
148
|
-
#
|
|
149
|
-
print(
|
|
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
|
+
)
|
|
150
233
|
for path in template_paths:
|
|
151
|
-
print(f" - {path}")
|
|
234
|
+
print(f" - {path}", file=sys.stderr)
|
|
152
235
|
|
|
153
236
|
# If we get here, we couldn't find the template
|
|
154
237
|
return None
|
|
155
238
|
|
|
156
239
|
|
|
157
240
|
def get_template_service_content():
|
|
158
|
-
"""
|
|
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.
|
|
159
250
|
|
|
160
251
|
Returns:
|
|
161
|
-
str:
|
|
252
|
+
str: Complete service file content to write. Read/access errors are reported to stderr.
|
|
162
253
|
"""
|
|
163
254
|
# Use the helper function to get the service template path
|
|
164
255
|
template_path = get_service_template_path()
|
|
@@ -166,49 +257,54 @@ def get_template_service_content():
|
|
|
166
257
|
if template_path and os.path.exists(template_path):
|
|
167
258
|
# Read the template from file
|
|
168
259
|
try:
|
|
169
|
-
with open(template_path, "r") as f:
|
|
260
|
+
with open(template_path, "r", encoding="utf-8") as f:
|
|
170
261
|
service_template = f.read()
|
|
171
262
|
return service_template
|
|
172
|
-
except
|
|
173
|
-
print(f"Error reading service template file: {e}")
|
|
263
|
+
except (OSError, IOError, UnicodeDecodeError) as e:
|
|
264
|
+
print(f"Error reading service template file: {e}", file=sys.stderr)
|
|
174
265
|
|
|
175
266
|
# If the helper function failed, try using importlib.resources directly
|
|
176
267
|
try:
|
|
177
268
|
service_template = (
|
|
178
269
|
importlib.resources.files("mmrelay.tools")
|
|
179
270
|
.joinpath("mmrelay.service")
|
|
180
|
-
.read_text()
|
|
271
|
+
.read_text(encoding="utf-8")
|
|
181
272
|
)
|
|
182
273
|
return service_template
|
|
183
|
-
except (FileNotFoundError, ImportError, OSError) as e:
|
|
184
|
-
print(
|
|
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
|
+
)
|
|
185
279
|
|
|
186
280
|
# Fall back to the file path method
|
|
187
281
|
template_path = get_template_service_path()
|
|
188
282
|
if template_path:
|
|
189
283
|
# Read the template from file
|
|
190
284
|
try:
|
|
191
|
-
with open(template_path, "r") as f:
|
|
285
|
+
with open(template_path, "r", encoding="utf-8") as f:
|
|
192
286
|
service_template = f.read()
|
|
193
287
|
return service_template
|
|
194
|
-
except
|
|
195
|
-
print(f"Error reading service template file: {e}")
|
|
288
|
+
except (OSError, IOError, UnicodeDecodeError) as e:
|
|
289
|
+
print(f"Error reading service template file: {e}", file=sys.stderr)
|
|
196
290
|
|
|
197
291
|
# If we couldn't find or read the template file, use a default template
|
|
198
|
-
print("Using default service template")
|
|
199
|
-
|
|
200
|
-
|
|
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
|
|
201
296
|
After=network-online.target
|
|
202
297
|
Wants=network-online.target
|
|
203
298
|
|
|
204
299
|
[Service]
|
|
205
300
|
Type=simple
|
|
206
301
|
# The mmrelay binary can be installed via pipx or pip
|
|
207
|
-
|
|
302
|
+
{resolved_exec_start}
|
|
208
303
|
WorkingDirectory=%h/.mmrelay
|
|
209
304
|
Restart=on-failure
|
|
210
305
|
RestartSec=10
|
|
211
306
|
Environment=PYTHONUNBUFFERED=1
|
|
307
|
+
Environment=LANG=C.UTF-8
|
|
212
308
|
# Ensure both pipx and pip environments are properly loaded
|
|
213
309
|
Environment=PATH=%h/.local/bin:%h/.local/pipx/venvs/mmrelay/bin:/usr/local/bin:/usr/bin:/bin
|
|
214
310
|
|
|
@@ -218,47 +314,59 @@ WantedBy=default.target
|
|
|
218
314
|
|
|
219
315
|
|
|
220
316
|
def is_service_enabled():
|
|
221
|
-
"""
|
|
317
|
+
"""
|
|
318
|
+
Return whether the user systemd service 'mmrelay.service' is enabled to start at login.
|
|
222
319
|
|
|
223
|
-
Returns
|
|
224
|
-
bool: True if the service is enabled, False otherwise.
|
|
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.
|
|
225
321
|
"""
|
|
226
322
|
try:
|
|
227
323
|
result = subprocess.run(
|
|
228
|
-
[
|
|
324
|
+
[SYSTEMCTL, "--user", "is-enabled", "mmrelay.service"],
|
|
229
325
|
check=False, # Don't raise an exception if the service is not enabled
|
|
230
326
|
capture_output=True,
|
|
231
327
|
text=True,
|
|
232
328
|
)
|
|
233
329
|
return result.returncode == 0 and result.stdout.strip() == "enabled"
|
|
234
|
-
except
|
|
330
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
331
|
+
print(f"Warning: Failed to check service enabled status: {e}", file=sys.stderr)
|
|
235
332
|
return False
|
|
236
333
|
|
|
237
334
|
|
|
238
335
|
def is_service_active():
|
|
239
|
-
"""
|
|
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.
|
|
240
342
|
|
|
241
343
|
Returns:
|
|
242
|
-
bool: True
|
|
344
|
+
bool: True when the service is active; False otherwise or on error.
|
|
243
345
|
"""
|
|
244
346
|
try:
|
|
245
347
|
result = subprocess.run(
|
|
246
|
-
[
|
|
348
|
+
[SYSTEMCTL, "--user", "is-active", "mmrelay.service"],
|
|
247
349
|
check=False, # Don't raise an exception if the service is not active
|
|
248
350
|
capture_output=True,
|
|
249
351
|
text=True,
|
|
250
352
|
)
|
|
251
353
|
return result.returncode == 0 and result.stdout.strip() == "active"
|
|
252
|
-
except
|
|
354
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
355
|
+
print(f"Warning: Failed to check service active status: {e}", file=sys.stderr)
|
|
253
356
|
return False
|
|
254
357
|
|
|
255
358
|
|
|
256
359
|
def create_service_file():
|
|
257
|
-
"""
|
|
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), and 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. The final unit is written to ~/.config/systemd/user/mmrelay.service.
|
|
364
|
+
|
|
365
|
+
Returns:
|
|
366
|
+
bool: True if the service file was written successfully; False if no template could be obtained or writing the file failed.
|
|
367
|
+
"""
|
|
368
|
+
# Get executable paths once to avoid duplicate calls and output
|
|
258
369
|
executable_path = get_executable_path()
|
|
259
|
-
if not executable_path:
|
|
260
|
-
print("Error: Could not find mmrelay executable in PATH")
|
|
261
|
-
return False
|
|
262
370
|
|
|
263
371
|
# Create service directory if it doesn't exist
|
|
264
372
|
service_dir = get_user_service_path().parent
|
|
@@ -271,7 +379,7 @@ def create_service_file():
|
|
|
271
379
|
# Get the template service content
|
|
272
380
|
service_template = get_template_service_content()
|
|
273
381
|
if not service_template:
|
|
274
|
-
print("Error: Could not find service template file")
|
|
382
|
+
print("Error: Could not find service template file", file=sys.stderr)
|
|
275
383
|
return False
|
|
276
384
|
|
|
277
385
|
# Replace placeholders with actual values
|
|
@@ -290,36 +398,64 @@ def create_service_file():
|
|
|
290
398
|
)
|
|
291
399
|
)
|
|
292
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
|
+
|
|
293
414
|
# Write service file
|
|
294
415
|
try:
|
|
295
|
-
get_user_service_path().write_text(service_content)
|
|
416
|
+
get_user_service_path().write_text(service_content, encoding="utf-8")
|
|
296
417
|
print(f"Service file created at {get_user_service_path()}")
|
|
297
418
|
return True
|
|
298
419
|
except (IOError, OSError) as e:
|
|
299
|
-
print(f"Error creating service file: {e}")
|
|
420
|
+
print(f"Error creating service file: {e}", file=sys.stderr)
|
|
300
421
|
return False
|
|
301
422
|
|
|
302
423
|
|
|
303
424
|
def reload_daemon():
|
|
304
|
-
"""
|
|
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
|
+
"""
|
|
305
430
|
try:
|
|
306
|
-
# Using
|
|
307
|
-
subprocess.run([
|
|
431
|
+
# Using resolved systemctl path
|
|
432
|
+
subprocess.run([SYSTEMCTL, "--user", "daemon-reload"], check=True)
|
|
308
433
|
print("Systemd user daemon reloaded")
|
|
309
434
|
return True
|
|
310
435
|
except subprocess.CalledProcessError as e:
|
|
311
|
-
print(f"Error reloading systemd daemon: {e}")
|
|
436
|
+
print(f"Error reloading systemd daemon: {e}", file=sys.stderr)
|
|
312
437
|
return False
|
|
313
438
|
except OSError as e:
|
|
314
|
-
print(f"Error: {e}")
|
|
439
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
315
440
|
return False
|
|
316
441
|
|
|
317
442
|
|
|
318
443
|
def service_needs_update():
|
|
319
|
-
"""
|
|
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.
|
|
320
456
|
|
|
321
457
|
Returns:
|
|
322
|
-
tuple: (needs_update, reason)
|
|
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).
|
|
323
459
|
"""
|
|
324
460
|
# Check if service already exists
|
|
325
461
|
existing_service = read_service_file()
|
|
@@ -328,30 +464,58 @@ def service_needs_update():
|
|
|
328
464
|
|
|
329
465
|
# Get the template service path
|
|
330
466
|
template_path = get_template_service_path()
|
|
331
|
-
if not template_path:
|
|
332
|
-
return False, "Could not find template service file"
|
|
333
467
|
|
|
334
|
-
# Get the executable
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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"
|
|
338
489
|
|
|
339
|
-
|
|
340
|
-
if executable_path not in existing_service:
|
|
490
|
+
if not any(exec_str in exec_start_line for exec_str in acceptable_execs):
|
|
341
491
|
return (
|
|
342
492
|
True,
|
|
343
|
-
|
|
493
|
+
"Service file does not use an acceptable executable "
|
|
494
|
+
f"({ ' or '.join(acceptable_execs) }).",
|
|
344
495
|
)
|
|
345
496
|
|
|
346
|
-
# Check if the PATH environment includes
|
|
347
|
-
|
|
348
|
-
|
|
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"
|
|
349
510
|
|
|
350
511
|
# Check if the service file has been modified recently
|
|
351
|
-
template_mtime = os.path.getmtime(template_path)
|
|
352
512
|
service_path = get_user_service_path()
|
|
353
|
-
if os.path.exists(service_path):
|
|
354
|
-
|
|
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"
|
|
355
519
|
if template_mtime > service_mtime:
|
|
356
520
|
return True, "Template service file is newer than installed service file"
|
|
357
521
|
|
|
@@ -359,52 +523,79 @@ def service_needs_update():
|
|
|
359
523
|
|
|
360
524
|
|
|
361
525
|
def check_loginctl_available():
|
|
362
|
-
"""
|
|
526
|
+
"""
|
|
527
|
+
Return True if `loginctl` is available and runnable on PATH.
|
|
363
528
|
|
|
364
|
-
|
|
365
|
-
|
|
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.
|
|
366
531
|
"""
|
|
532
|
+
path = shutil.which("loginctl")
|
|
533
|
+
if not path:
|
|
534
|
+
return False
|
|
367
535
|
try:
|
|
368
536
|
result = subprocess.run(
|
|
369
|
-
[
|
|
370
|
-
check=False,
|
|
371
|
-
capture_output=True,
|
|
372
|
-
text=True,
|
|
537
|
+
[path, "--version"], check=False, capture_output=True, text=True
|
|
373
538
|
)
|
|
374
539
|
return result.returncode == 0
|
|
375
|
-
except
|
|
540
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
541
|
+
print(f"Warning: Failed to check loginctl availability: {e}", file=sys.stderr)
|
|
376
542
|
return False
|
|
377
543
|
|
|
378
544
|
|
|
379
545
|
def check_lingering_enabled():
|
|
380
546
|
"""
|
|
381
|
-
|
|
547
|
+
Return whether systemd user "lingering" is enabled for the current user.
|
|
382
548
|
|
|
383
|
-
|
|
384
|
-
|
|
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.
|
|
385
553
|
"""
|
|
386
554
|
try:
|
|
387
|
-
|
|
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
|
|
388
569
|
result = subprocess.run(
|
|
389
|
-
[
|
|
570
|
+
[loginctl, "show-user", username, "--property=Linger"],
|
|
390
571
|
check=False,
|
|
391
572
|
capture_output=True,
|
|
392
573
|
text=True,
|
|
393
574
|
)
|
|
394
575
|
return result.returncode == 0 and "Linger=yes" in result.stdout
|
|
395
|
-
except
|
|
396
|
-
print(f"Error checking lingering status: {e}")
|
|
576
|
+
except (OSError, subprocess.SubprocessError, KeyError, RuntimeError) as e:
|
|
577
|
+
print(f"Error checking lingering status: {e}", file=sys.stderr)
|
|
397
578
|
return False
|
|
398
579
|
|
|
399
580
|
|
|
400
581
|
def enable_lingering():
|
|
401
|
-
"""
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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.
|
|
405
586
|
"""
|
|
406
587
|
try:
|
|
407
|
-
|
|
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
|
|
408
599
|
print(f"Enabling lingering for user {username}...")
|
|
409
600
|
result = subprocess.run(
|
|
410
601
|
["sudo", "loginctl", "enable-linger", username],
|
|
@@ -416,10 +607,10 @@ def enable_lingering():
|
|
|
416
607
|
print("Lingering enabled successfully")
|
|
417
608
|
return True
|
|
418
609
|
else:
|
|
419
|
-
print(f"Error enabling lingering: {result.stderr}")
|
|
610
|
+
print(f"Error enabling lingering: {result.stderr}", file=sys.stderr)
|
|
420
611
|
return False
|
|
421
|
-
except
|
|
422
|
-
print(f"Error enabling lingering: {e}")
|
|
612
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
613
|
+
print(f"Error enabling lingering: {e}", file=sys.stderr)
|
|
423
614
|
return False
|
|
424
615
|
|
|
425
616
|
|
|
@@ -470,7 +661,8 @@ def install_service():
|
|
|
470
661
|
# Reload daemon (continue even if this fails)
|
|
471
662
|
if not reload_daemon():
|
|
472
663
|
print(
|
|
473
|
-
"Warning: Failed to reload systemd daemon. You may need to run 'systemctl --user daemon-reload' manually."
|
|
664
|
+
"Warning: Failed to reload systemd daemon. You may need to run 'systemctl --user daemon-reload' manually.",
|
|
665
|
+
file=sys.stderr,
|
|
474
666
|
)
|
|
475
667
|
|
|
476
668
|
if existing_service:
|
|
@@ -522,15 +714,15 @@ def install_service():
|
|
|
522
714
|
if enable_service:
|
|
523
715
|
try:
|
|
524
716
|
subprocess.run(
|
|
525
|
-
[
|
|
717
|
+
[SYSTEMCTL, "--user", "enable", "mmrelay.service"],
|
|
526
718
|
check=True,
|
|
527
719
|
)
|
|
528
720
|
print("Service enabled successfully")
|
|
529
721
|
service_enabled = True
|
|
530
722
|
except subprocess.CalledProcessError as e:
|
|
531
|
-
print(f"Error enabling service: {e}")
|
|
723
|
+
print(f"Error enabling service: {e}", file=sys.stderr)
|
|
532
724
|
except OSError as e:
|
|
533
|
-
print(f"Error: {e}")
|
|
725
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
534
726
|
|
|
535
727
|
# Check if the service is already running
|
|
536
728
|
service_active = is_service_active()
|
|
@@ -546,7 +738,7 @@ def install_service():
|
|
|
546
738
|
if restart_service:
|
|
547
739
|
try:
|
|
548
740
|
subprocess.run(
|
|
549
|
-
[
|
|
741
|
+
[SYSTEMCTL, "--user", "restart", "mmrelay.service"],
|
|
550
742
|
check=True,
|
|
551
743
|
)
|
|
552
744
|
print("Service restarted successfully")
|
|
@@ -555,9 +747,9 @@ def install_service():
|
|
|
555
747
|
# Show service status
|
|
556
748
|
show_service_status()
|
|
557
749
|
except subprocess.CalledProcessError as e:
|
|
558
|
-
print(f"Error restarting service: {e}")
|
|
750
|
+
print(f"Error restarting service: {e}", file=sys.stderr)
|
|
559
751
|
except OSError as e:
|
|
560
|
-
print(f"Error: {e}")
|
|
752
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
561
753
|
else:
|
|
562
754
|
print("The service is not currently running.")
|
|
563
755
|
try:
|
|
@@ -591,33 +783,36 @@ def install_service():
|
|
|
591
783
|
|
|
592
784
|
|
|
593
785
|
def start_service():
|
|
594
|
-
"""
|
|
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.
|
|
595
791
|
|
|
596
792
|
Returns:
|
|
597
|
-
bool: True
|
|
793
|
+
bool: True when the service was started successfully; False on error.
|
|
598
794
|
"""
|
|
599
795
|
try:
|
|
600
|
-
subprocess.run(
|
|
601
|
-
["/usr/bin/systemctl", "--user", "start", "mmrelay.service"], check=True
|
|
602
|
-
)
|
|
796
|
+
subprocess.run([SYSTEMCTL, "--user", "start", "mmrelay.service"], check=True)
|
|
603
797
|
return True
|
|
604
798
|
except subprocess.CalledProcessError as e:
|
|
605
|
-
print(f"Error starting service: {e}")
|
|
799
|
+
print(f"Error starting service: {e}", file=sys.stderr)
|
|
606
800
|
return False
|
|
607
801
|
except OSError as e:
|
|
608
|
-
print(f"Error: {e}")
|
|
802
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
609
803
|
return False
|
|
610
804
|
|
|
611
805
|
|
|
612
806
|
def show_service_status():
|
|
613
|
-
"""
|
|
807
|
+
"""
|
|
808
|
+
Show the systemd user status for the mmrelay.service and print it to stdout.
|
|
614
809
|
|
|
615
|
-
|
|
616
|
-
|
|
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.
|
|
617
812
|
"""
|
|
618
813
|
try:
|
|
619
814
|
result = subprocess.run(
|
|
620
|
-
[
|
|
815
|
+
[SYSTEMCTL, "--user", "status", "mmrelay.service"],
|
|
621
816
|
check=True,
|
|
622
817
|
capture_output=True,
|
|
623
818
|
text=True,
|
|
@@ -626,8 +821,8 @@ def show_service_status():
|
|
|
626
821
|
print(result.stdout)
|
|
627
822
|
return True
|
|
628
823
|
except subprocess.CalledProcessError as e:
|
|
629
|
-
print(f"Could not get service status: {e}")
|
|
824
|
+
print(f"Could not get service status: {e}", file=sys.stderr)
|
|
630
825
|
return False
|
|
631
826
|
except OSError as e:
|
|
632
|
-
print(f"Error: {e}")
|
|
827
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
633
828
|
return False
|