#!/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: /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: 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())