mmrelay 1.2.1__py3-none-any.whl → 1.2.3__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/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
- This function tries to find the mmrelay executable in the PATH,
25
- which works for both pipx and pip installations.
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
- print(f"Found mmrelay executable at: {mmrelay_path}")
30
- return mmrelay_path
31
- else:
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
- return sys.executable
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
- Displays a progress spinner while waiting up to 10 seconds for the mmrelay service to become active.
104
+ Wait up to ~10 seconds for the user mmrelay systemd service to become active.
60
105
 
61
- The function checks the service status after 5 seconds and completes early if the service is detected as active.
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 rich.progress import Progress, SpinnerColumn, TextColumn, TimeElapsedColumn
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
- with Progress(
69
- SpinnerColumn(),
70
- TextColumn("[bold green]Starting mmrelay service..."),
71
- TimeElapsedColumn(),
72
- transient=True,
73
- ) as progress:
74
- # Add a task that will run for approximately 10 seconds
75
- task = progress.add_task("Starting", total=PROGRESS_TOTAL_STEPS)
76
-
77
- # Update progress over 10 seconds
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
- """Read the content of the service file if it exists."""
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
- """Find the path to the template service file.
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: The path to the template service file, or None if not found.
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
- # Debug output to help diagnose issues
149
- print("Debug: Could not find mmrelay.service in any of these locations:")
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
- """Get the content of the template service file.
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: The content of the template service file, or a default template if not found.
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 Exception as e:
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(f"Error accessing mmrelay.service via importlib.resources: {e}")
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 Exception as e:
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
- return """[Unit]
200
- Description=A Meshtastic <=> Matrix Relay
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
- ExecStart=%h/.local/bin/mmrelay --config %h/.mmrelay/config.yaml --logfile %h/.mmrelay/logs/mmrelay.log
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
- """Check if the service is enabled.
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
- ["/usr/bin/systemctl", "--user", "is-enabled", "mmrelay.service"],
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 Exception:
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
- """Check if the service is active (running).
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 if the service is active, False otherwise.
344
+ bool: True when the service is active; False otherwise or on error.
243
345
  """
244
346
  try:
245
347
  result = subprocess.run(
246
- ["/usr/bin/systemctl", "--user", "is-active", "mmrelay.service"],
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 Exception:
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
- """Create the systemd user 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
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
- """Reload the systemd user 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
+ """
305
430
  try:
306
- # Using absolute path for security
307
- subprocess.run(["/usr/bin/systemctl", "--user", "daemon-reload"], check=True)
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
- """Check if the service file needs to be updated.
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) where needs_update is a boolean and reason is a string
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 path
335
- executable_path = get_executable_path()
336
- if not executable_path:
337
- return False, "Could not find mmrelay executable"
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
- # Check if the ExecStart line in the existing service file contains the correct executable
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
- f"Service file does not use the current executable: {executable_path}",
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 pipx paths
347
- if "%h/.local/pipx/venvs/mmrelay/bin" not in existing_service:
348
- return True, "Service file does not include pipx paths in PATH environment"
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
- service_mtime = os.path.getmtime(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"
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
- """Check if loginctl is available on the system.
526
+ """
527
+ Return True if `loginctl` is available and runnable on PATH.
363
528
 
364
- Returns:
365
- bool: True if loginctl is available, False otherwise.
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
- ["which", "loginctl"],
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 Exception:
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
- Determine whether user lingering is enabled for the current user.
547
+ Return whether systemd user "lingering" is enabled for the current user.
382
548
 
383
- Returns:
384
- bool: True if user lingering is enabled, False otherwise.
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
- username = os.environ.get("USER", os.environ.get("USERNAME"))
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
- ["loginctl", "show-user", username, "--property=Linger"],
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 Exception as e:
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
- """Enable user lingering using sudo.
582
+ """
583
+ Enable systemd "lingering" for the current user by running `sudo loginctl enable-linger <user>`.
402
584
 
403
- Returns:
404
- bool: True if lingering was enabled successfully, False otherwise.
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
- username = os.environ.get("USER", os.environ.get("USERNAME"))
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 Exception as e:
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
- ["/usr/bin/systemctl", "--user", "enable", "mmrelay.service"],
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
- ["/usr/bin/systemctl", "--user", "restart", "mmrelay.service"],
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
- """Start the systemd user 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.
595
791
 
596
792
  Returns:
597
- bool: True if successful, False otherwise.
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
- """Show the status of the systemd user service.
807
+ """
808
+ Show the systemd user status for the mmrelay.service and print it to stdout.
614
809
 
615
- Returns:
616
- bool: True if successful, False otherwise.
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
- ["/usr/bin/systemctl", "--user", "status", "mmrelay.service"],
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