1307 lines
44 KiB
Python
1307 lines
44 KiB
Python
#!/usr/bin/env python3
|
|
"""Run the Hydrus client (top-level helper)
|
|
|
|
This standalone helper is intended to live in the project's top-level `scripts/`
|
|
folder so it remains available even if the Hydrus repository subfolder is not
|
|
present or its copy of this helper gets removed.
|
|
|
|
Features (subset of the repo helper):
|
|
- Locate repository venv (default: <workspace>/hydrusnetwork/.venv)
|
|
- Install or reinstall scripts/requirements.txt into the venv
|
|
- Verify key imports
|
|
- Launch hydrus_client.py (foreground or detached)
|
|
- Install/uninstall simple user-level start-on-boot services (schtasks/systemd/crontab)
|
|
|
|
Usage examples:
|
|
python scripts/run_client.py --verify
|
|
python scripts/run_client.py --detached --headless
|
|
python scripts/run_client.py --install-deps --verify
|
|
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import os
|
|
import pwd
|
|
import shlex
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import List, Optional
|
|
|
|
|
|
def get_python_in_venv(venv_dir: Path) -> Optional[Path]:
|
|
try:
|
|
v = Path(venv_dir)
|
|
# Windows
|
|
win_python = v / "Scripts" / "python.exe"
|
|
if win_python.exists():
|
|
return win_python
|
|
# Unix
|
|
unix_python = v / "bin" / "python"
|
|
if unix_python.exists():
|
|
return unix_python
|
|
unix_py3 = v / "bin" / "python3"
|
|
if unix_py3.exists():
|
|
return unix_py3
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
def find_requirements(root: Path) -> Optional[Path]:
|
|
candidates = [root / "scripts" / "requirements.txt", root / "requirements.txt", root / "client" / "requirements.txt"]
|
|
for c in candidates:
|
|
if c.exists():
|
|
return c
|
|
# shallow two-level search
|
|
try:
|
|
for p in root.iterdir():
|
|
if not p.is_dir():
|
|
continue
|
|
for child in (p,
|
|
):
|
|
candidate = child / "requirements.txt"
|
|
if candidate.exists():
|
|
return candidate
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
def install_requirements(
|
|
venv_py: Path,
|
|
req_path: Path,
|
|
reinstall: bool = False,
|
|
upgrade: bool = False
|
|
) -> bool:
|
|
try:
|
|
# Suppression flag for Windows
|
|
kwargs = {}
|
|
if os.name == "nt":
|
|
kwargs["creationflags"] = 0x08000000
|
|
|
|
print(f"Installing/Updating {req_path} into venv ({venv_py})...")
|
|
subprocess.run(
|
|
[str(venv_py),
|
|
"-m",
|
|
"pip",
|
|
"install",
|
|
"--upgrade",
|
|
"pip"],
|
|
check=True,
|
|
**kwargs
|
|
)
|
|
install_cmd = [str(venv_py), "-m", "pip", "install", "-r", str(req_path)]
|
|
if upgrade:
|
|
install_cmd = [str(venv_py), "-m", "pip", "install", "--upgrade", "-r", str(req_path)]
|
|
if reinstall:
|
|
install_cmd = [
|
|
str(venv_py),
|
|
"-m",
|
|
"pip",
|
|
"install",
|
|
"--upgrade",
|
|
"--force-reinstall",
|
|
"-r",
|
|
str(req_path),
|
|
]
|
|
subprocess.run(install_cmd, check=True, **kwargs)
|
|
return True
|
|
except subprocess.CalledProcessError as e:
|
|
print("Failed to install requirements:", e)
|
|
return False
|
|
|
|
|
|
def parse_requirements_file(req_path: Path) -> List[str]:
|
|
names: List[str] = []
|
|
try:
|
|
with req_path.open("r", encoding="utf-8") as fh:
|
|
for raw in fh:
|
|
line = raw.strip()
|
|
if not line or line.startswith("#"):
|
|
continue
|
|
if line.startswith("-e") or line.startswith("--"):
|
|
continue
|
|
if "://" in line or line.startswith("file:"):
|
|
continue
|
|
line = line.split(";")[0].strip()
|
|
line = line.split("[")[0].strip()
|
|
for sep in ("==", ">=", "<=", "~=", "!=", ">", "<", "==="):
|
|
if sep in line:
|
|
line = line.split(sep)[0].strip()
|
|
if " @ " in line:
|
|
line = line.split(" @ ")[0].strip()
|
|
if line:
|
|
names.append(line.split()[0].strip().lower())
|
|
except Exception:
|
|
pass
|
|
return names
|
|
|
|
|
|
def verify_imports(venv_py: Path, packages: List[str]) -> bool:
|
|
# Skip mpv check as it is problematic to install and causes slow startups
|
|
packages = [p for p in packages if p.lower() != "mpv"]
|
|
|
|
# Map some package names to import names (handle common cases where package name differs from import name)
|
|
import_map = {
|
|
"pyyaml": "yaml",
|
|
"pillow": "PIL",
|
|
"python-dateutil": "dateutil",
|
|
"beautifulsoup4": "bs4",
|
|
"pillow-heif": "pillow_heif",
|
|
"pillow-jxl-plugin": "pillow_jxl",
|
|
"pyopenssl": "OpenSSL",
|
|
"pysocks": "socks",
|
|
"service-identity": "service_identity",
|
|
"show-in-file-manager": "showinfm",
|
|
"opencv-python-headless": "cv2",
|
|
"pyside6": "PySide6",
|
|
}
|
|
|
|
# Helper for silent subprocess execution on Windows
|
|
def _run_silent(cmd, **kwargs):
|
|
if os.name == "nt":
|
|
# 0x08000000 = CREATE_NO_WINDOW
|
|
kwargs["creationflags"] = kwargs.get("creationflags", 0) | 0x08000000
|
|
return subprocess.run(cmd, **kwargs)
|
|
|
|
missing = []
|
|
for pkg in packages:
|
|
try:
|
|
out = _run_silent(
|
|
[str(venv_py),
|
|
"-m",
|
|
"pip",
|
|
"show",
|
|
pkg],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
timeout=10,
|
|
)
|
|
except subprocess.TimeoutExpired:
|
|
missing.append(pkg)
|
|
continue
|
|
except Exception:
|
|
missing.append(pkg)
|
|
continue
|
|
|
|
if out.returncode != 0 or not out.stdout.strip():
|
|
missing.append(pkg)
|
|
continue
|
|
|
|
import_name = import_map.get(pkg, pkg)
|
|
try:
|
|
_run_silent(
|
|
[str(venv_py),
|
|
"-c",
|
|
f"import {import_name}"],
|
|
check=True,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
timeout=10,
|
|
)
|
|
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
|
|
missing.append(pkg)
|
|
if missing:
|
|
print(
|
|
"The following packages were not importable in the venv:",
|
|
", ".join(missing)
|
|
)
|
|
return False
|
|
return True
|
|
|
|
|
|
def is_first_run(repo_root: Path) -> bool:
|
|
try:
|
|
db_dir = repo_root / "db"
|
|
if db_dir.exists() and any(db_dir.iterdir()):
|
|
return False
|
|
for f in repo_root.glob("*.db"):
|
|
if f.exists():
|
|
return False
|
|
except Exception:
|
|
return False
|
|
return True
|
|
|
|
|
|
def _user_exists(username: str) -> bool:
|
|
try:
|
|
pwd.getpwnam(username)
|
|
return True
|
|
except KeyError:
|
|
return False
|
|
|
|
|
|
def ensure_service_user(username: str) -> bool:
|
|
if not username:
|
|
return False
|
|
if _user_exists(username):
|
|
return True
|
|
useradd = shutil.which("useradd")
|
|
if not useradd:
|
|
print("useradd not found; cannot create service user.")
|
|
return False
|
|
shell = "/usr/sbin/nologin" if Path("/usr/sbin/nologin").exists() else "/bin/false"
|
|
cmd = [
|
|
useradd,
|
|
"--system",
|
|
"--no-create-home",
|
|
"--shell",
|
|
shell,
|
|
username,
|
|
]
|
|
try:
|
|
subprocess.run(cmd, check=True)
|
|
print(f"Created service user '{username}'.")
|
|
return True
|
|
except FileNotFoundError:
|
|
print("useradd executable missing; cannot create service user.")
|
|
return False
|
|
except subprocess.CalledProcessError as exc:
|
|
print(f"Failed to create service user '{username}': {exc}")
|
|
return False
|
|
|
|
|
|
def grant_service_user_repo_access(repo_root: Path, username: str) -> bool:
|
|
try:
|
|
for dirpath, dirnames, filenames in os.walk(repo_root):
|
|
shutil.chown(dirpath, user=username, group=username)
|
|
for name in dirnames + filenames:
|
|
path = Path(dirpath) / name
|
|
shutil.chown(path, user=username, group=username)
|
|
return True
|
|
except PermissionError as exc:
|
|
print(
|
|
f"Failed to grant ownership of '{repo_root}' to '{username}': {exc}"
|
|
)
|
|
return False
|
|
except Exception as exc:
|
|
print(
|
|
f"Error while adjusting permissions for '{username}' in '{repo_root}': {exc}"
|
|
)
|
|
return False
|
|
|
|
|
|
# --- Service install/uninstall helpers -----------------------------------
|
|
|
|
|
|
def install_service_windows(
|
|
service_name: str,
|
|
repo_root: Path,
|
|
venv_py: Path,
|
|
headless: bool = True,
|
|
detached: bool = True,
|
|
start_on: str = "logon",
|
|
pull: bool = False,
|
|
workspace_root: Optional[Path] = None,
|
|
) -> bool:
|
|
try:
|
|
schtasks = shutil.which("schtasks")
|
|
if not schtasks:
|
|
print(
|
|
"schtasks not available on this system; cannot install Windows scheduled task."
|
|
)
|
|
return False
|
|
|
|
# Use the repository root for the service wrapper script
|
|
bat = repo_root / "run-client.bat"
|
|
|
|
# If there's a local copy of run_client.py in the target repo, use that instead
|
|
# of the one from Medios-Macina to keep the service independent.
|
|
local_helper = repo_root / "run_client.py"
|
|
if not local_helper.exists():
|
|
local_helper = repo_root / "scripts" / "run_client.py"
|
|
|
|
target_script = local_helper if local_helper.exists() else Path(__file__).resolve()
|
|
|
|
python_exe = venv_py
|
|
# Use pythonw.exe for windowless execution on Windows
|
|
pythonw_exe = python_exe.parent / "pythonw.exe"
|
|
if not pythonw_exe.exists():
|
|
pythonw_exe = python_exe
|
|
|
|
if not bat.exists():
|
|
# The .bat remains using python.exe for manual/interactive runs
|
|
content = f'@echo off\n"{python_exe}" "{target_script}" %*\n'
|
|
bat.write_text(content, encoding="utf-8")
|
|
|
|
sc = "ONLOGON" if start_on == "logon" else "ONSTART"
|
|
|
|
# When running as a service, we DO NOT use --detached.
|
|
# This keeps the run_client.py process alive as a monitor for the task scheduler,
|
|
# preventing it from thinking the task finished/crashed and trying to restart it.
|
|
task_args = ""
|
|
if headless: task_args += "--headless "
|
|
if pull: task_args += "--pull "
|
|
# Force the correct repo root for the service
|
|
task_args += f'--repo-root "{repo_root}" '
|
|
|
|
# Use pythonw for the task to avoid console window
|
|
tr_command = f'"{pythonw_exe}" "{target_script}" {task_args.strip()}'
|
|
|
|
cmd = [
|
|
schtasks,
|
|
"/Create",
|
|
"/SC",
|
|
sc,
|
|
"/TN",
|
|
service_name,
|
|
"/TR",
|
|
tr_command,
|
|
"/RL",
|
|
"LIMITED",
|
|
"/F",
|
|
]
|
|
subprocess.run(cmd, check=True)
|
|
print(f"Scheduled task '{service_name}' created ({sc}).")
|
|
return True
|
|
except subprocess.CalledProcessError as e:
|
|
print("Failed to create scheduled task:", e)
|
|
return False
|
|
except Exception as exc:
|
|
print("Windows install-service error:", exc)
|
|
return False
|
|
|
|
|
|
def uninstall_service_windows(service_name: str) -> bool:
|
|
try:
|
|
schtasks = shutil.which("schtasks")
|
|
if not schtasks:
|
|
print(
|
|
"schtasks not available on this system; cannot remove scheduled task."
|
|
)
|
|
return False
|
|
cmd = [schtasks, "/Delete", "/TN", service_name, "/F"]
|
|
subprocess.run(cmd, check=True)
|
|
print(f"Scheduled task '{service_name}' removed.")
|
|
return True
|
|
except subprocess.CalledProcessError as e:
|
|
print("Failed to delete scheduled task:", e)
|
|
return False
|
|
except Exception as exc:
|
|
print("Windows uninstall-service error:", exc)
|
|
return False
|
|
|
|
|
|
def install_service_systemd(
|
|
service_name: str,
|
|
repo_root: Path,
|
|
venv_py: Path,
|
|
headless: bool = True,
|
|
detached: bool = True,
|
|
pull: bool = False,
|
|
workspace_root: Optional[Path] = None,
|
|
service_user: Optional[str] = None,
|
|
) -> bool:
|
|
try:
|
|
helper_path = Path(__file__).resolve()
|
|
print(f"Installing systemd user service via {helper_path}...")
|
|
print("systemctl env:", {
|
|
"DBUS_SESSION_BUS_ADDRESS": os.environ.get("DBUS_SESSION_BUS_ADDRESS"),
|
|
"XDG_RUNTIME_DIR": os.environ.get("XDG_RUNTIME_DIR"),
|
|
"HOME": os.environ.get("HOME"),
|
|
})
|
|
systemctl = shutil.which("systemctl")
|
|
if not systemctl:
|
|
print(
|
|
"systemctl not available; falling back to crontab @reboot (if present)."
|
|
)
|
|
return install_service_cron(
|
|
service_name,
|
|
repo_root,
|
|
venv_py,
|
|
headless,
|
|
detached,
|
|
pull=pull,
|
|
workspace_root=workspace_root
|
|
)
|
|
|
|
if (
|
|
not os.environ.get("DBUS_SESSION_BUS_ADDRESS")
|
|
or not os.environ.get("XDG_RUNTIME_DIR")
|
|
):
|
|
print(
|
|
"DBUS_SESSION_BUS_ADDRESS/XDG_RUNTIME_DIR not set; skipping systemd user install"
|
|
)
|
|
return install_service_cron(
|
|
service_name,
|
|
repo_root,
|
|
venv_py,
|
|
headless=headless,
|
|
detached=detached,
|
|
pull=pull,
|
|
workspace_root=workspace_root,
|
|
)
|
|
|
|
if os.name != "nt" and hasattr(os, "geteuid") and os.geteuid() == 0:
|
|
print(
|
|
"Running as root; installing system-wide systemd service instead."
|
|
)
|
|
return install_service_systemd_system(
|
|
service_name,
|
|
repo_root,
|
|
venv_py,
|
|
headless=headless,
|
|
detached=detached,
|
|
pull=pull,
|
|
workspace_root=workspace_root,
|
|
service_user=service_user,
|
|
)
|
|
|
|
unit_dir = Path.home() / ".config" / "systemd" / "user"
|
|
unit_dir.mkdir(parents=True, exist_ok=True)
|
|
unit_file = unit_dir / f"{service_name}.service"
|
|
|
|
# Prefer local helper if it exists
|
|
local_helper = repo_root / "run_client.py"
|
|
if not local_helper.exists():
|
|
local_helper = repo_root / "scripts" / "run_client.py"
|
|
target_script = local_helper if local_helper.exists() else Path(__file__).resolve()
|
|
|
|
exec_args = f'"{venv_py}" "{target_script}" --detached '
|
|
if headless: exec_args += "--headless "
|
|
if pull: exec_args += "--pull "
|
|
exec_args += f'--repo-root "{repo_root}" '
|
|
|
|
content = f"[Unit]\nDescription=Medios-Macina Client\nAfter=network.target\n\n[Service]\nType=simple\nExecStart={exec_args}\nWorkingDirectory={str(repo_root)}\nRestart=on-failure\nEnvironment=PYTHONUNBUFFERED=1\n\n[Install]\nWantedBy=default.target\n"
|
|
unit_file.write_text(content, encoding="utf-8")
|
|
def _run_systemctl(cmd: List[str]) -> subprocess.CompletedProcess:
|
|
try:
|
|
return subprocess.run(
|
|
cmd,
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
except Exception as exc:
|
|
raise
|
|
|
|
failed = False
|
|
for cmd in [
|
|
[systemctl, "--user", "daemon-reload"],
|
|
[systemctl,
|
|
"--user",
|
|
"enable",
|
|
"--now",
|
|
f"{service_name}.service"],
|
|
]:
|
|
result = _run_systemctl(cmd)
|
|
if result.returncode != 0:
|
|
stderr = (result.stderr or "").strip()
|
|
stdout = (result.stdout or "").strip()
|
|
err_lines = "\n".join([l for l in [stderr, stdout] if l])
|
|
print("Failed to run systemctl", cmd, "exit", result.returncode)
|
|
if err_lines:
|
|
print(err_lines)
|
|
if "user scope bus" in err_lines.lower() or "xdg_runtime_dir" in err_lines.lower():
|
|
print("systemd user bus unavailable; falling back to cron @reboot install.")
|
|
return install_service_cron(
|
|
service_name,
|
|
repo_root,
|
|
venv_py,
|
|
headless=headless,
|
|
detached=detached,
|
|
pull=pull,
|
|
workspace_root=workspace_root,
|
|
)
|
|
failed = True
|
|
break
|
|
|
|
if failed:
|
|
return False
|
|
|
|
print(f"systemd user service '{service_name}' installed and started.")
|
|
return True
|
|
except Exception as exc:
|
|
print("systemd install error:", exc)
|
|
return False
|
|
|
|
|
|
def uninstall_service_systemd(service_name: str) -> bool:
|
|
try:
|
|
systemctl = shutil.which("systemctl")
|
|
if not systemctl:
|
|
print("systemctl not available; cannot uninstall systemd service.")
|
|
return False
|
|
subprocess.run(
|
|
[systemctl,
|
|
"--user",
|
|
"disable",
|
|
"--now",
|
|
f"{service_name}.service"],
|
|
check=False
|
|
)
|
|
unit_file = Path.home(
|
|
) / ".config" / "systemd" / "user" / f"{service_name}.service"
|
|
if unit_file.exists():
|
|
unit_file.unlink()
|
|
subprocess.run([systemctl, "--user", "daemon-reload"], check=True)
|
|
print(f"systemd user service '{service_name}' removed.")
|
|
return True
|
|
except Exception as exc:
|
|
print("systemd uninstall error:", exc)
|
|
return False
|
|
|
|
|
|
def install_service_systemd_system(
|
|
service_name: str,
|
|
repo_root: Path,
|
|
venv_py: Path,
|
|
headless: bool = True,
|
|
detached: bool = True,
|
|
pull: bool = False,
|
|
workspace_root: Optional[Path] = None,
|
|
service_user: Optional[str] = None,
|
|
) -> bool:
|
|
try:
|
|
systemctl = shutil.which("systemctl")
|
|
if not systemctl:
|
|
print(
|
|
"systemctl not available; falling back to crontab @reboot (if present)."
|
|
)
|
|
return install_service_cron(
|
|
service_name,
|
|
repo_root,
|
|
venv_py,
|
|
headless,
|
|
detached,
|
|
pull=pull,
|
|
workspace_root=workspace_root,
|
|
)
|
|
|
|
if service_user and not ensure_service_user(service_user):
|
|
print(f"Unable to prepare service user '{service_user}' for system service.")
|
|
return False
|
|
if service_user and not grant_service_user_repo_access(repo_root, service_user):
|
|
print(
|
|
f"Failed to assign '{service_user}' as the owner of '{repo_root}'."
|
|
)
|
|
return False
|
|
|
|
unit_dir = Path("/etc/systemd/system")
|
|
service_file = unit_dir / f"{service_name}.service"
|
|
unit_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
local_helper = repo_root / "run_client.py"
|
|
if not local_helper.exists():
|
|
local_helper = repo_root / "scripts" / "run_client.py"
|
|
target_script = local_helper if local_helper.exists() else Path(__file__).resolve()
|
|
|
|
exec_args = f'"{venv_py}" "{target_script}" --detached '
|
|
if headless:
|
|
exec_args += "--headless "
|
|
if pull:
|
|
exec_args += "--pull "
|
|
exec_args += f'--repo-root "{repo_root}" '
|
|
|
|
service_lines = [
|
|
"[Unit]",
|
|
"Description=Medios-Macina Client (system service)",
|
|
"After=network.target",
|
|
"",
|
|
"[Service]",
|
|
"Type=simple",
|
|
f"ExecStart={exec_args}",
|
|
f"WorkingDirectory={repo_root}",
|
|
"Restart=on-failure",
|
|
"Environment=PYTHONUNBUFFERED=1",
|
|
]
|
|
if service_user:
|
|
service_lines.append(f"User={service_user}")
|
|
service_lines.append(f"Group={service_user}")
|
|
service_lines.extend([
|
|
"",
|
|
"[Install]",
|
|
"WantedBy=multi-user.target",
|
|
])
|
|
content = "\n".join(service_lines) + "\n"
|
|
service_file.write_text(content, encoding="utf-8")
|
|
|
|
for cmd in [
|
|
[systemctl, "daemon-reload"],
|
|
[systemctl, "enable", "--now", f"{service_name}.service"],
|
|
]:
|
|
subprocess.run(cmd, check=True)
|
|
|
|
print(f"system-wide systemd service '{service_name}' enabled and started.")
|
|
return True
|
|
except subprocess.CalledProcessError as exc:
|
|
print("Failed to install system-wide service:", exc)
|
|
return False
|
|
except PermissionError as exc:
|
|
print("Permission denied while writing systemd unit:", exc)
|
|
return False
|
|
except Exception as exc:
|
|
print("system-wide install error:", exc)
|
|
return False
|
|
|
|
|
|
def install_service_cron(
|
|
service_name: str,
|
|
repo_root: Path,
|
|
venv_py: Path,
|
|
headless: bool = True,
|
|
detached: bool = True,
|
|
pull: bool = False,
|
|
workspace_root: Optional[Path] = None,
|
|
) -> bool:
|
|
try:
|
|
crontab = shutil.which("crontab")
|
|
if not crontab:
|
|
print("crontab not available; cannot install reboot cron job.")
|
|
return False
|
|
|
|
# Prefer local helper if it exists
|
|
local_helper = repo_root / "run_client.py"
|
|
if not local_helper.exists():
|
|
local_helper = repo_root / "scripts" / "run_client.py"
|
|
target_script = local_helper if local_helper.exists() else Path(__file__).resolve()
|
|
|
|
exec_args = f'"{venv_py}" "{target_script}" --detached '
|
|
if headless: exec_args += "--headless "
|
|
if pull: exec_args += "--pull "
|
|
exec_args += f'--repo-root "{repo_root}" '
|
|
|
|
entry = f"@reboot {exec_args} # {service_name}\n"
|
|
proc = subprocess.run(
|
|
[crontab,
|
|
"-l"],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True
|
|
)
|
|
existing = proc.stdout if proc.returncode == 0 else ""
|
|
if entry.strip() in existing:
|
|
print("Crontab entry already present; skipping.")
|
|
return True
|
|
new = existing + "\n" + entry
|
|
subprocess.run([crontab, "-"], input=new, text=True, check=True)
|
|
print(f"Crontab @reboot entry added for '{service_name}'.")
|
|
return True
|
|
except subprocess.CalledProcessError as e:
|
|
print("Failed to install crontab entry:", e)
|
|
return False
|
|
except Exception as exc:
|
|
print("crontab install error:", exc)
|
|
return False
|
|
|
|
|
|
def uninstall_service_cron(service_name: str, repo_root: Path, venv_py: Path) -> bool:
|
|
try:
|
|
crontab = shutil.which("crontab")
|
|
if not crontab:
|
|
print("crontab not available; cannot remove reboot cron job.")
|
|
return False
|
|
proc = subprocess.run(
|
|
[crontab,
|
|
"-l"],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True
|
|
)
|
|
if proc.returncode != 0:
|
|
print("No crontab found for user; nothing to remove.")
|
|
return True
|
|
lines = [l for l in proc.stdout.splitlines() if f"# {service_name}" not in l]
|
|
new = "\n".join(lines) + "\n"
|
|
subprocess.run([crontab, "-"], input=new, text=True, check=True)
|
|
print(f"Crontab entry for '{service_name}' removed.")
|
|
return True
|
|
except subprocess.CalledProcessError as e:
|
|
print("Failed to modify crontab:", e)
|
|
return False
|
|
except Exception as exc:
|
|
print("crontab uninstall error:", exc)
|
|
return False
|
|
|
|
|
|
def install_service_auto(
|
|
service_name: str,
|
|
repo_root: Path,
|
|
venv_py: Path,
|
|
headless: bool = True,
|
|
detached: bool = True,
|
|
pull: bool = False,
|
|
workspace_root: Optional[Path] = None,
|
|
service_user: Optional[str] = None,
|
|
) -> bool:
|
|
try:
|
|
if os.name == "nt":
|
|
return install_service_windows(
|
|
service_name,
|
|
repo_root,
|
|
venv_py,
|
|
headless=headless,
|
|
detached=detached,
|
|
pull=pull,
|
|
workspace_root=workspace_root
|
|
)
|
|
else:
|
|
if shutil.which("systemctl"):
|
|
return install_service_systemd(
|
|
service_name,
|
|
repo_root,
|
|
venv_py,
|
|
headless=headless,
|
|
detached=detached,
|
|
pull=pull,
|
|
workspace_root=workspace_root,
|
|
service_user=service_user,
|
|
)
|
|
else:
|
|
return install_service_cron(
|
|
service_name,
|
|
repo_root,
|
|
venv_py,
|
|
headless=headless,
|
|
detached=detached,
|
|
pull=pull,
|
|
workspace_root=workspace_root
|
|
)
|
|
except Exception as exc:
|
|
print("install_service_auto error:", exc)
|
|
return False
|
|
except Exception as exc:
|
|
print("install_service_auto error:", exc)
|
|
return False
|
|
|
|
|
|
def uninstall_service_auto(service_name: str, repo_root: Path, venv_py: Path) -> bool:
|
|
try:
|
|
if os.name == "nt":
|
|
return uninstall_service_windows(service_name)
|
|
else:
|
|
if shutil.which("systemctl"):
|
|
return uninstall_service_systemd(service_name)
|
|
else:
|
|
return uninstall_service_cron(service_name, repo_root, venv_py)
|
|
except Exception as exc:
|
|
print("uninstall_service_auto error:", exc)
|
|
return False
|
|
|
|
|
|
def print_activation_instructions(
|
|
repo_root: Path,
|
|
venv_dir: Path,
|
|
venv_py: Path
|
|
) -> None:
|
|
print("\nActivation and run examples:")
|
|
# PowerShell
|
|
print(f" PowerShell:\n . {shlex.quote(str(venv_dir))}\\Scripts\\Activate.ps1")
|
|
# CMD
|
|
print(f" CMD:\n {str(venv_dir)}\\Scripts\\activate.bat")
|
|
# Bash
|
|
print(f" Bash (Linux/macOS/WSL):\n source {str(venv_dir)}/bin/activate")
|
|
print(
|
|
f"\nDirect run without activating:\n {str(venv_py)} {str(repo_root/ 'hydrus_client.py')}"
|
|
)
|
|
|
|
|
|
def detach_kwargs_for_platform():
|
|
kwargs = {}
|
|
if os.name == "nt":
|
|
# Flags to ensure the process is detached and has NO console window
|
|
CREATE_NEW_PROCESS_GROUP = 0x00000200
|
|
DETACHED_PROCESS = 0x00000008
|
|
CREATE_NO_WINDOW = 0x08000000
|
|
kwargs["creationflags"] = CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS | CREATE_NO_WINDOW
|
|
else:
|
|
kwargs["start_new_session"] = True
|
|
return kwargs
|
|
|
|
|
|
def find_venv_python(repo_root: Path,
|
|
venv_arg: Optional[str],
|
|
venv_name: str) -> Optional[Path]:
|
|
# venv_arg may be a python executable or a directory
|
|
if venv_arg:
|
|
p = Path(venv_arg)
|
|
if p.exists():
|
|
if p.is_file():
|
|
return p
|
|
else:
|
|
found = get_python_in_venv(p)
|
|
if found:
|
|
return found
|
|
# Try repo-local venv
|
|
dir_candidate = repo_root / venv_name
|
|
found = get_python_in_venv(dir_candidate)
|
|
if found:
|
|
return found
|
|
# Fallback: if current interpreter is inside repo venv
|
|
try:
|
|
cur = Path(sys.executable).resolve()
|
|
if repo_root in cur.parents:
|
|
return cur
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
def _python_can_import(python_exe: Path, modules: List[str]) -> bool:
|
|
"""Return True if the given python executable can import all modules in the list.
|
|
|
|
Uses a subprocess to avoid side-effects in the current interpreter.
|
|
"""
|
|
if not python_exe:
|
|
return False
|
|
try:
|
|
# Build a short import test string. Use semicolons to ensure any import error results in non-zero exit.
|
|
imports = ";".join([f"import {m}" for m in modules])
|
|
out = subprocess.run(
|
|
[str(python_exe),
|
|
"-c",
|
|
imports],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
timeout=10,
|
|
)
|
|
return out.returncode == 0
|
|
except (subprocess.TimeoutExpired, Exception):
|
|
return False
|
|
|
|
|
|
def main(argv: Optional[List[str]] = None) -> int:
|
|
p = argparse.ArgumentParser(
|
|
description=
|
|
"Run hydrus_client.py using the repo-local venv Python (top-level helper)"
|
|
)
|
|
p.add_argument(
|
|
"--venv",
|
|
help="Path to venv dir or python executable (overrides default .venv)"
|
|
)
|
|
p.add_argument(
|
|
"--venv-name",
|
|
default=".venv",
|
|
help="Name of the venv folder to look for (default: .venv)"
|
|
)
|
|
p.add_argument(
|
|
"--client",
|
|
default="hydrus_client.py",
|
|
help="Path to hydrus_client.py relative to repo root",
|
|
)
|
|
p.add_argument(
|
|
"--repo-root",
|
|
default=None,
|
|
help="Path to the hydrus repository root (overrides auto-detection)",
|
|
)
|
|
p.add_argument(
|
|
"--install-deps",
|
|
action="store_true",
|
|
help="Install requirements.txt into the venv before running",
|
|
)
|
|
p.add_argument(
|
|
"--reinstall",
|
|
action="store_true",
|
|
help=
|
|
"Force re-install dependencies from requirements.txt into the venv (uses --force-reinstall)",
|
|
)
|
|
p.add_argument(
|
|
"--verify",
|
|
action="store_true",
|
|
help=
|
|
"Verify that packages from requirements.txt are importable in the venv (after install)",
|
|
)
|
|
p.add_argument(
|
|
"--headless",
|
|
action="store_true",
|
|
help=
|
|
"Attempt to launch the client without showing the Qt GUI (best-effort). Default for subsequent runs; first run will show GUI unless --headless is supplied",
|
|
)
|
|
p.add_argument(
|
|
"--pull",
|
|
action="store_true",
|
|
help="Run 'git pull' before starting the client",
|
|
)
|
|
p.add_argument(
|
|
"--update-deps",
|
|
action="store_true",
|
|
help="Update python dependencies to latest compatible versions on startup",
|
|
)
|
|
p.add_argument(
|
|
"--gui",
|
|
action="store_true",
|
|
help="Start the client with the GUI visible (overrides headless/default) ",
|
|
)
|
|
p.add_argument(
|
|
"--detached",
|
|
action="store_true",
|
|
help="Start the client and do not wait (detached)"
|
|
)
|
|
p.add_argument(
|
|
"--install-service",
|
|
action="store_true",
|
|
help=
|
|
"Install a user-level start-on-boot service/scheduled task for the hydrus client",
|
|
)
|
|
p.add_argument(
|
|
"--uninstall-service",
|
|
action="store_true",
|
|
help="Remove an installed start-on-boot service/scheduled task",
|
|
)
|
|
p.add_argument(
|
|
"--service-name",
|
|
default="hydrus-client",
|
|
help="Name of the service / scheduled task to install (default: hydrus-client)",
|
|
)
|
|
p.add_argument(
|
|
"--service-user",
|
|
default=None,
|
|
help="When installing a system-wide unit as root, optionally run it under this user (default: hydrusnetwork)",
|
|
)
|
|
p.add_argument(
|
|
"--cwd",
|
|
default=None,
|
|
help="Working directory to start the client in (default: repo root)"
|
|
)
|
|
p.add_argument("--quiet", action="store_true", help="Reduce output")
|
|
p.add_argument(
|
|
"client_args",
|
|
nargs=argparse.REMAINDER,
|
|
help="Arguments to pass to hydrus_client.py (prefix with --)",
|
|
)
|
|
|
|
args = p.parse_args(argv)
|
|
|
|
script_dir = Path(__file__).resolve().parent
|
|
if (script_dir / "hydrus_client.py").exists():
|
|
workspace_root = script_dir
|
|
else:
|
|
workspace_root = script_dir.parent
|
|
|
|
if args.repo_root:
|
|
repo_root = Path(args.repo_root).expanduser().resolve()
|
|
else:
|
|
if (workspace_root / "hydrus_client.py").exists():
|
|
repo_root = workspace_root
|
|
else:
|
|
candidate = workspace_root / "hydrusnetwork"
|
|
if candidate.exists():
|
|
repo_root = candidate
|
|
else:
|
|
repo_root = workspace_root
|
|
|
|
# Handle git pull update if requested
|
|
# Skip execution during service install/uninstall; it will run when the service starts
|
|
if args.pull and not (args.install_service or args.uninstall_service):
|
|
if shutil.which("git"):
|
|
if (repo_root / ".git").exists():
|
|
if not args.quiet:
|
|
print(f"Updating repository via 'git pull' in {repo_root}...")
|
|
try:
|
|
# Use creationflags to hide the window on Windows
|
|
k = {}
|
|
if os.name == "nt":
|
|
k["creationflags"] = 0x08000000
|
|
subprocess.run(["git", "pull"], cwd=str(repo_root), check=False, **k)
|
|
except Exception as e:
|
|
print(f"Warning: git pull failed: {e}")
|
|
else:
|
|
if not args.quiet:
|
|
print("Skipping 'git pull': directory is not a git repository.")
|
|
else:
|
|
if not args.quiet:
|
|
print("Skipping 'git pull': 'git' not found on PATH.")
|
|
|
|
venv_py = find_venv_python(repo_root, args.venv, args.venv_name)
|
|
|
|
def _is_running_in_virtualenv() -> bool:
|
|
try:
|
|
return hasattr(sys,
|
|
"real_prefix") or getattr(sys,
|
|
"base_prefix",
|
|
None
|
|
) != getattr(sys,
|
|
"prefix",
|
|
None)
|
|
except Exception:
|
|
return False
|
|
|
|
# Skip heavy verification if we are just installing/uninstalling a service
|
|
do_verify = args.verify and not (args.install_service or args.uninstall_service)
|
|
|
|
# Prefer the current interpreter if the helper was invoked from a virtualenv
|
|
# and the user did not explicitly pass --venv. This matches the user's likely
|
|
# intent when they called: <venv_python> scripts/run_client.py ...
|
|
cur_py = Path(sys.executable)
|
|
|
|
# However, if we've already found a repo-local venv and the current Python
|
|
# is external to the repository, we do NOT prefer it yet - we'll verify the
|
|
# repo-local one first. This prevents tools like Medios-Macina from
|
|
# accidentally installing their own venv into the repo's services.
|
|
cur_is_external = True
|
|
try:
|
|
if repo_root in cur_py.resolve().parents:
|
|
cur_is_external = False
|
|
except Exception:
|
|
pass
|
|
|
|
if args.venv is None and _is_running_in_virtualenv() and cur_py and (not venv_py or not cur_is_external):
|
|
# If current interpreter looks like a venv and can import required modules,
|
|
# prefer it immediately rather than forcing the repo venv.
|
|
req = find_requirements(repo_root)
|
|
pkgs = parse_requirements_file(req) if req and do_verify else []
|
|
check_pkgs = pkgs if pkgs else ["pyyaml"]
|
|
|
|
ok_cur = False
|
|
if do_verify:
|
|
try:
|
|
ok_cur = verify_imports(cur_py, check_pkgs)
|
|
except Exception:
|
|
ok_cur = _python_can_import(cur_py, ["yaml"])
|
|
else:
|
|
# If skipping verification, assume current is OK if it's the right version
|
|
ok_cur = True
|
|
|
|
if ok_cur:
|
|
venv_py = cur_py
|
|
if not args.quiet:
|
|
print(f"Using current Python interpreter as venv: {cur_py}")
|
|
|
|
# If we found a repo-local venv, verify it has at least the core imports (or the
|
|
# packages listed in requirements.txt). If not, prefer the current Python
|
|
# interpreter when that interpreter looks more suitable (e.g. has deps installed).
|
|
if venv_py and venv_py != cur_py and do_verify:
|
|
if not args.quiet:
|
|
print(f"Found venv python: {venv_py}")
|
|
req = find_requirements(repo_root)
|
|
pkgs = parse_requirements_file(req) if req else []
|
|
check_pkgs = pkgs if pkgs else ["pyyaml"]
|
|
try:
|
|
ok_venv = verify_imports(venv_py, check_pkgs)
|
|
except Exception:
|
|
ok_venv = _python_can_import(venv_py, ["yaml"])
|
|
# ... logic continues below
|
|
|
|
if not ok_venv:
|
|
try:
|
|
ok_cur = verify_imports(cur_py, check_pkgs)
|
|
except Exception:
|
|
ok_cur = _python_can_import(cur_py, ["yaml"])
|
|
|
|
if ok_cur:
|
|
if not args.quiet:
|
|
print(
|
|
f"Repository venv ({venv_py}) is missing required packages; using current Python at {cur_py} instead."
|
|
)
|
|
venv_py = cur_py
|
|
else:
|
|
print(
|
|
"Warning: repository venv appears to be missing required packages. If the client fails to start, run this helper with --install-deps to install requirements into the repo venv, or use --venv to point to a Python that has the deps."
|
|
)
|
|
|
|
if not venv_py:
|
|
print("Could not locate a repository venv.")
|
|
print(
|
|
"Create one with: python -m venv .venv (inside your hydrus repo) and then re-run this helper, or use the installer to create it for you."
|
|
)
|
|
print_activation_instructions(
|
|
repo_root,
|
|
repo_root / args.venv_name,
|
|
repo_root / args.venv_name
|
|
)
|
|
return 2
|
|
|
|
client_path = (repo_root / args.client).resolve()
|
|
if not client_path.exists():
|
|
print(f"Client file not found: {client_path}")
|
|
return 3
|
|
|
|
cwd = Path(args.cwd).resolve() if args.cwd else repo_root
|
|
|
|
# Optionally install dependencies
|
|
# Automatically update dependencies if we pulled new code or if forced via env/flag
|
|
should_update = args.update_deps or os.environ.get("MM_UPDATE_DEPS") == "1"
|
|
|
|
# Check config.conf for auto_update_deps
|
|
config_path = repo_root / "config.conf"
|
|
if not should_update and config_path.exists():
|
|
try:
|
|
with open(config_path, "r", encoding="utf-8") as f:
|
|
content = f.read()
|
|
if "auto_update_deps=true" in content.lower().replace(" ", ""):
|
|
should_update = True
|
|
except Exception:
|
|
pass
|
|
|
|
if not should_update and args.pull and not (args.install_service or args.uninstall_service):
|
|
should_update = True
|
|
|
|
if args.install_deps or args.reinstall or should_update:
|
|
req = find_requirements(repo_root)
|
|
if not req:
|
|
print("No requirements.txt found; skipping install")
|
|
else:
|
|
ok = install_requirements(venv_py, req, reinstall=args.reinstall, upgrade=should_update)
|
|
if not ok:
|
|
print("Dependency installation failed; aborting")
|
|
return 4
|
|
if args.verify:
|
|
pkgs = parse_requirements_file(req)
|
|
if pkgs:
|
|
okv = verify_imports(venv_py, pkgs)
|
|
if not okv:
|
|
print(
|
|
"Verification failed; see instructions above to re-run installation."
|
|
)
|
|
|
|
# If not installing but user asked to verify, do verification only
|
|
if args.verify and not (args.install_deps or args.reinstall or should_update):
|
|
req = find_requirements(repo_root)
|
|
if req:
|
|
pkgs = parse_requirements_file(req)
|
|
if pkgs and not verify_imports(venv_py, pkgs):
|
|
print(
|
|
"Verification found missing packages. Use --install-deps to install into the venv."
|
|
)
|
|
|
|
# If the venv appears to be missing required packages, offer to install them interactively
|
|
if do_verify:
|
|
req = find_requirements(repo_root)
|
|
pkgs = parse_requirements_file(req) if req else []
|
|
check_pkgs = pkgs if pkgs else ["pyyaml"]
|
|
try:
|
|
venv_ok = verify_imports(venv_py, check_pkgs)
|
|
except Exception:
|
|
venv_ok = _python_can_import(venv_py, ["yaml"]) # fallback
|
|
|
|
if not venv_ok:
|
|
# If user explicitly requested install, we've already attempted it above; otherwise, do not block.
|
|
if args.install_deps or args.reinstall:
|
|
# if we already did an install attempt and it still fails, bail
|
|
print("Dependency verification failed after install; aborting.")
|
|
return 4
|
|
|
|
# Default: print a clear warning and proceed to launch with the repository venv
|
|
print(
|
|
"Warning: repository venv appears to be missing required packages. Proceeding to launch with repository venv; the client may fail to start. Use --install-deps to install requirements into the repo venv."
|
|
)
|
|
|
|
# Service install/uninstall requests
|
|
if args.install_service or args.uninstall_service:
|
|
first_run = is_first_run(repo_root)
|
|
if args.gui:
|
|
use_headless = False
|
|
elif args.headless:
|
|
use_headless = True
|
|
else:
|
|
use_headless = not first_run
|
|
|
|
service_user = args.service_user.strip() if args.service_user else None
|
|
if (
|
|
args.install_service
|
|
and not service_user
|
|
and os.name != "nt"
|
|
and hasattr(os, "geteuid")
|
|
and os.geteuid() == 0
|
|
):
|
|
service_user = "hydrusnetwork"
|
|
|
|
if args.install_service:
|
|
ok = install_service_auto(
|
|
args.service_name,
|
|
repo_root,
|
|
venv_py,
|
|
headless=use_headless,
|
|
detached=True,
|
|
pull=args.pull,
|
|
workspace_root=workspace_root,
|
|
service_user=service_user
|
|
)
|
|
return 0 if ok else 6
|
|
if args.uninstall_service:
|
|
ok = uninstall_service_auto(args.service_name, repo_root, venv_py)
|
|
return 0 if ok else 7
|
|
|
|
# Determine headless vs GUI
|
|
if args.gui:
|
|
headless = False
|
|
elif args.headless:
|
|
headless = True
|
|
else:
|
|
# Default to GUI for the client launcher
|
|
headless = False
|
|
|
|
# On Windows, if we are headless, use pythonw.exe for the client too to avoid a console.
|
|
if os.name == "nt" and headless:
|
|
pw = venv_py.parent / "pythonw.exe"
|
|
if pw.exists():
|
|
venv_py = pw
|
|
|
|
# Prepare the command
|
|
client_args = args.client_args or []
|
|
cmd = [str(venv_py), str(client_path)] + client_args
|
|
|
|
if not args.quiet and is_first_run(repo_root):
|
|
print("First run detected: defaulting to GUI unless --headless is specified.")
|
|
|
|
env = os.environ.copy()
|
|
if headless:
|
|
if os.name == "posix" and shutil.which("xvfb-run"):
|
|
xvfb_cmd = [
|
|
"xvfb-run",
|
|
"--auto-servernum",
|
|
"--server-args=-screen 0 1024x768x24"
|
|
]
|
|
cmd = xvfb_cmd + cmd
|
|
if not args.quiet:
|
|
print("Headless: using xvfb-run to provide a virtual X server")
|
|
else:
|
|
env["QT_QPA_PLATFORM"] = "offscreen"
|
|
if not args.quiet:
|
|
print("Headless: setting QT_QPA_PLATFORM=offscreen (best-effort)")
|
|
|
|
# Inform which Python will be used
|
|
if not args.quiet:
|
|
try:
|
|
print(f"Launching Hydrus client with Python: {venv_py}")
|
|
print(f"Command: {' '.join(shlex.quote(str(c)) for c in cmd)}")
|
|
except Exception:
|
|
pass
|
|
|
|
# Launch
|
|
if args.detached:
|
|
try:
|
|
kwargs = detach_kwargs_for_platform()
|
|
kwargs.update({
|
|
"cwd": str(cwd),
|
|
"env": env
|
|
})
|
|
subprocess.Popen(cmd, **kwargs)
|
|
print("Hydrus client launched (detached).")
|
|
return 0
|
|
except Exception as exc:
|
|
print("Failed to launch client detached:", exc)
|
|
return 5
|
|
else:
|
|
p = None
|
|
try:
|
|
# On Windows, if we are already using pythonw.exe, we don't need CREATE_NO_WINDOW
|
|
# as the process already has no console. Avoiding extra flags helps with
|
|
# service termination mapping.
|
|
kwargs = {}
|
|
if os.name == "nt" and headless and "pythonw.exe" not in str(venv_py).lower():
|
|
kwargs["creationflags"] = 0x08000000
|
|
|
|
p = subprocess.Popen(cmd, cwd=str(cwd), env=env, **kwargs)
|
|
p.wait()
|
|
return p.returncode
|
|
except Exception as e:
|
|
if not args.quiet:
|
|
print(f"Hydrus client error: {e}")
|
|
return 5
|
|
finally:
|
|
# Ensure the child process is terminated if the monitor process is killed
|
|
if p and p.poll() is None:
|
|
try:
|
|
p.terminate()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|