2025-12-29 17:05:03 -08:00
#!/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)
2025-12-31 22:05:25 -08:00
- Install or reinstall scripts/requirements.txt into the venv
2025-12-29 17:05:03 -08:00
- 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
2026-04-01 14:36:36 -07:00
import re
2025-12-29 17:05:03 -08:00
import shlex
import shutil
import subprocess
import sys
from pathlib import Path
from typing import List , Optional
2026-04-01 14:36:36 -07:00
try :
import pwd
except ImportError :
pwd = None
2025-12-29 17:05:03 -08:00
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 ]:
2025-12-31 22:05:25 -08:00
candidates = [ root / "scripts" / "requirements.txt" , root / "requirements.txt" , root / "client" / "requirements.txt" ]
2025-12-29 17:05:03 -08:00
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
2025-12-29 18:42:02 -08:00
for child in ( p ,
):
2025-12-29 17:05:03 -08:00
candidate = child / "requirements.txt"
if candidate . exists ():
return candidate
except Exception :
pass
return None
2025-12-29 18:42:02 -08:00
def install_requirements (
venv_py : Path ,
2026-04-01 14:36:36 -07:00
repo_root : Path ,
2025-12-29 18:42:02 -08:00
req_path : Path ,
2026-02-04 20:51:54 -08:00
reinstall : bool = False ,
upgrade : bool = False
2025-12-29 18:42:02 -08:00
) -> bool :
2025-12-29 17:05:03 -08:00
try :
2026-01-22 01:09:09 -08:00
# Suppression flag for Windows
kwargs = {}
if os . name == "nt" :
kwargs [ "creationflags" ] = 0x08000000
2026-02-04 20:51:54 -08:00
print ( f "Installing/Updating { req_path } into venv ( { venv_py } )..." )
2025-12-29 18:42:02 -08:00
subprocess . run (
[ str ( venv_py ),
"-m" ,
"pip" ,
"install" ,
"--upgrade" ,
"pip" ],
2026-01-22 01:09:09 -08:00
check = True ,
** kwargs
2025-12-29 18:42:02 -08:00
)
2026-04-01 14:36:36 -07:00
python_version = get_python_version_info ( venv_py )
install_cmd = [ str ( venv_py ), "-m" , "pip" , "install" , "--prefer-binary" ]
2026-02-04 20:51:54 -08:00
if upgrade :
2026-04-01 14:36:36 -07:00
install_cmd . append ( "--upgrade" )
2025-12-29 17:05:03 -08:00
if reinstall :
2026-04-01 14:36:36 -07:00
install_cmd . extend ([ "--upgrade" , "--force-reinstall" ])
install_cmd . extend ( build_hydrus_install_targets ( req_path , repo_root , python_version ))
2026-01-22 01:09:09 -08:00
subprocess . run ( install_cmd , check = True , ** kwargs )
2025-12-29 17:05:03 -08:00
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
2026-04-01 14:36:36 -07:00
def get_python_version_info ( python_exe : Path ) -> Optional [ tuple [ int , int , int ]]:
try :
result = subprocess . run (
[
str ( python_exe ),
"-c" ,
"import sys; print('.'.join(str(part) for part in sys.version_info[:3]))" ,
],
stdout = subprocess . PIPE ,
stderr = subprocess . DEVNULL ,
text = True ,
check = True ,
timeout = 10 ,
)
version_text = ( result . stdout or "" ) . strip ()
major , minor , micro = version_text . split ( "." , 2 )
return int ( major ), int ( minor ), int ( micro )
except Exception :
return None
def is_hydrus_repo ( repo_root : Path ) -> bool :
return ( repo_root / "setup_venv.py" ) . exists () and ( repo_root / "hydrus_client.py" ) . exists ()
def build_hydrus_install_targets (
req_path : Path ,
repo_root : Path ,
python_version : Optional [ tuple [ int , int , int ]],
) -> List [ str ]:
if python_version is None or python_version < ( 3 , 13 ) or not is_hydrus_repo ( repo_root ):
return [ "-r" , str ( req_path )]
overrides = {
"pyside6" : "PySide6==6.10.1" ,
"qtpy" : "QtPy==2.4.3" ,
"opencv-python-headless" : "opencv-python-headless==4.13.0.90" ,
"numpy" : "numpy==2.4.1" ,
}
targets : List [ str ] = []
seen_packages = set ()
for raw_line in req_path . read_text ( encoding = "utf-8" ) . splitlines ():
line = raw_line . strip ()
if not line or line . startswith ( "#" ):
continue
package_name = re . split ( r "[<>=!~\[]" , line , maxsplit = 1 )[ 0 ] . strip () . lower ()
if package_name in overrides :
line = overrides [ package_name ]
targets . append ( line )
seen_packages . add ( package_name )
if os . name == "nt" and "pywin32" not in seen_packages :
targets . append ( "pywin32" )
print (
f "Using Hydrus compatibility dependency set for Python { python_version [ 0 ] } . { python_version [ 1 ] } . { python_version [ 2 ] } "
)
return targets
2025-12-29 17:05:03 -08:00
def verify_imports ( venv_py : Path , packages : List [ str ]) -> bool :
2026-01-22 12:11:05 -08:00
# Skip mpv check as it is problematic to install and causes slow startups
packages = [ p for p in packages if p . lower () != "mpv" ]
2025-12-29 17:05:03 -08:00
# 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" ,
2026-01-21 22:52:52 -08:00
"pillow-jxl-plugin" : "pillow_jxl" ,
2025-12-29 17:05:03 -08:00
"pyopenssl" : "OpenSSL" ,
"pysocks" : "socks" ,
"service-identity" : "service_identity" ,
2026-01-21 22:52:52 -08:00
"show-in-file-manager" : "showinfm" ,
2025-12-29 17:05:03 -08:00
"opencv-python-headless" : "cv2" ,
"pyside6" : "PySide6" ,
}
2026-01-22 01:09:09 -08:00
# 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 )
2025-12-29 17:05:03 -08:00
missing = []
for pkg in packages :
try :
2026-01-22 01:09:09 -08:00
out = _run_silent (
2025-12-29 18:42:02 -08:00
[ str ( venv_py ),
"-m" ,
"pip" ,
"show" ,
pkg ],
2025-12-29 17:05:03 -08:00
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 :
2026-01-22 01:09:09 -08:00
_run_silent (
2025-12-29 18:42:02 -08:00
[ str ( venv_py ),
"-c" ,
f "import { import_name } " ],
2025-12-29 17:05:03 -08:00
check = True ,
stdout = subprocess . DEVNULL ,
stderr = subprocess . DEVNULL ,
timeout = 10 ,
)
except ( subprocess . CalledProcessError , subprocess . TimeoutExpired ):
missing . append ( pkg )
if missing :
2025-12-29 18:42:02 -08:00
print (
"The following packages were not importable in the venv:" ,
", " . join ( missing )
)
2025-12-29 17:05:03 -08:00
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
2026-01-23 03:23:02 -08:00
def _user_exists ( username : str ) -> bool :
2026-04-01 14:36:36 -07:00
if pwd is None :
return False
2026-01-23 03:23:02 -08:00
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
2026-01-23 03:48:41 -08:00
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
2025-12-29 17:05:03 -08:00
# --- 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" ,
2026-01-22 00:22:42 -08:00
pull : bool = False ,
workspace_root : Optional [ Path ] = None ,
2025-12-29 17:05:03 -08:00
) -> bool :
try :
schtasks = shutil . which ( "schtasks" )
if not schtasks :
2025-12-29 18:42:02 -08:00
print (
"schtasks not available on this system; cannot install Windows scheduled task."
)
2025-12-29 17:05:03 -08:00
return False
2026-01-22 01:09:09 -08:00
# 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 ()
2026-01-22 00:22:42 -08:00
python_exe = venv_py
2026-01-22 01:09:09 -08:00
# Use pythonw.exe for windowless execution on Windows
pythonw_exe = python_exe . parent / "pythonw.exe"
if not pythonw_exe . exists ():
pythonw_exe = python_exe
2026-01-22 00:22:42 -08:00
2025-12-29 17:05:03 -08:00
if not bat . exists ():
2026-01-22 01:09:09 -08:00
# The .bat remains using python.exe for manual/interactive runs
content = f '@echo off \n " { python_exe } " " { target_script } " %* \n '
2025-12-29 17:05:03 -08:00
bat . write_text ( content , encoding = "utf-8" )
sc = "ONLOGON" if start_on == "logon" else "ONSTART"
2026-01-22 00:22:42 -08:00
2026-01-22 01:09:09 -08:00
# 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 = ""
2026-01-22 00:22:42 -08:00
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 } " '
2026-01-22 01:09:09 -08:00
# Use pythonw for the task to avoid console window
tr_command = f '" { pythonw_exe } " " { target_script } " { task_args . strip () } '
2026-01-22 00:22:42 -08:00
2025-12-29 17:05:03 -08:00
cmd = [
schtasks ,
"/Create" ,
"/SC" ,
sc ,
"/TN" ,
service_name ,
"/TR" ,
2026-01-22 01:09:09 -08:00
tr_command ,
2025-12-29 17:05:03 -08:00
"/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 :
2025-12-29 18:42:02 -08:00
print (
"schtasks not available on this system; cannot remove scheduled task."
)
2025-12-29 17:05:03 -08:00
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 (
2025-12-29 18:42:02 -08:00
service_name : str ,
repo_root : Path ,
venv_py : Path ,
headless : bool = True ,
2026-01-22 00:22:42 -08:00
detached : bool = True ,
pull : bool = False ,
workspace_root : Optional [ Path ] = None ,
2026-01-23 03:23:02 -08:00
service_user : Optional [ str ] = None ,
2025-12-29 17:05:03 -08:00
) -> bool :
try :
2026-01-23 03:00:14 -08:00
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" ),
})
2025-12-29 17:05:03 -08:00
systemctl = shutil . which ( "systemctl" )
if not systemctl :
2025-12-29 18:42:02 -08:00
print (
"systemctl not available; falling back to crontab @reboot (if present)."
)
return install_service_cron (
service_name ,
repo_root ,
venv_py ,
headless ,
2026-01-22 00:22:42 -08:00
detached ,
pull = pull ,
workspace_root = workspace_root
2025-12-29 18:42:02 -08:00
)
2025-12-29 17:05:03 -08:00
2026-01-23 03:00:14 -08:00
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 ,
)
2026-01-23 03:02:21 -08:00
if os . name != "nt" and hasattr ( os , "geteuid" ) and os . geteuid () == 0 :
print (
2026-01-23 03:10:40 -08:00
"Running as root; installing system-wide systemd service instead."
2026-01-23 03:02:21 -08:00
)
2026-01-23 03:10:40 -08:00
return install_service_systemd_system (
2026-01-23 03:02:21 -08:00
service_name ,
repo_root ,
venv_py ,
headless = headless ,
detached = detached ,
pull = pull ,
workspace_root = workspace_root ,
2026-01-23 03:23:02 -08:00
service_user = service_user ,
2026-01-23 03:02:21 -08:00
)
2025-12-29 17:05:03 -08:00
unit_dir = Path . home () / ".config" / "systemd" / "user"
unit_dir . mkdir ( parents = True , exist_ok = True )
unit_file = unit_dir / f " { service_name } .service"
2026-01-22 00:22:42 -08:00
2026-01-22 01:09:09 -08:00
# 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 '
2026-01-22 00:22:42 -08:00
if headless : exec_args += "--headless "
if pull : exec_args += "--pull "
exec_args += f '--repo-root " { repo_root } " '
2026-01-22 01:09:09 -08:00
content = f "[Unit] \n Description=Medios-Macina Client \n After=network.target \n\n [Service] \n Type=simple \n ExecStart= { exec_args } \n WorkingDirectory= { str ( repo_root ) } \n Restart=on-failure \n Environment=PYTHONUNBUFFERED=1 \n\n [Install] \n WantedBy=default.target \n "
2025-12-29 17:05:03 -08:00
unit_file . write_text ( content , encoding = "utf-8" )
2026-01-23 02:46:16 -08:00
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" ],
2025-12-29 18:42:02 -08:00
[ systemctl ,
"--user" ,
"enable" ,
"--now" ,
f " { service_name } .service" ],
2026-01-23 02:46:16 -08:00
]:
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
2025-12-29 17:05:03 -08:00
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 (
2025-12-29 18:42:02 -08:00
[ systemctl ,
"--user" ,
"disable" ,
"--now" ,
f " { service_name } .service" ],
check = False
2025-12-29 17:05:03 -08:00
)
2025-12-29 18:42:02 -08:00
unit_file = Path . home (
) / ".config" / "systemd" / "user" / f " { service_name } .service"
2025-12-29 17:05:03 -08:00
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
2026-01-23 03:23:02 -08:00
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
2026-01-23 03:48:41 -08:00
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
2026-01-23 03:23:02 -08:00
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
2025-12-29 17:05:03 -08:00
def install_service_cron (
2025-12-29 18:42:02 -08:00
service_name : str ,
repo_root : Path ,
venv_py : Path ,
headless : bool = True ,
2026-01-22 00:22:42 -08:00
detached : bool = True ,
pull : bool = False ,
workspace_root : Optional [ Path ] = None ,
2025-12-29 17:05:03 -08:00
) -> bool :
try :
crontab = shutil . which ( "crontab" )
if not crontab :
print ( "crontab not available; cannot install reboot cron job." )
return False
2026-01-22 00:22:42 -08:00
2026-01-22 01:09:09 -08:00
# 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 '
2026-01-22 00:22:42 -08:00
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 "
2025-12-29 17:05:03 -08:00
proc = subprocess . run (
2025-12-29 18:42:02 -08:00
[ crontab ,
"-l" ],
stdout = subprocess . PIPE ,
stderr = subprocess . PIPE ,
text = True
2025-12-29 17:05:03 -08:00
)
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 (
2025-12-29 18:42:02 -08:00
[ crontab ,
"-l" ],
stdout = subprocess . PIPE ,
stderr = subprocess . PIPE ,
text = True
2025-12-29 17:05:03 -08:00
)
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 (
2025-12-29 18:42:02 -08:00
service_name : str ,
repo_root : Path ,
venv_py : Path ,
headless : bool = True ,
2026-01-22 00:22:42 -08:00
detached : bool = True ,
pull : bool = False ,
workspace_root : Optional [ Path ] = None ,
2026-01-23 03:48:41 -08:00
service_user : Optional [ str ] = None ,
2025-12-29 17:05:03 -08:00
) -> bool :
try :
if os . name == "nt" :
return install_service_windows (
2025-12-29 18:42:02 -08:00
service_name ,
repo_root ,
venv_py ,
headless = headless ,
2026-01-22 00:22:42 -08:00
detached = detached ,
pull = pull ,
workspace_root = workspace_root
2025-12-29 17:05:03 -08:00
)
else :
if shutil . which ( "systemctl" ):
return install_service_systemd (
2025-12-29 18:42:02 -08:00
service_name ,
repo_root ,
venv_py ,
headless = headless ,
2026-01-22 00:22:42 -08:00
detached = detached ,
pull = pull ,
2026-01-23 03:48:41 -08:00
workspace_root = workspace_root ,
service_user = service_user ,
2025-12-29 17:05:03 -08:00
)
else :
return install_service_cron (
2025-12-29 18:42:02 -08:00
service_name ,
repo_root ,
venv_py ,
headless = headless ,
2026-01-22 00:22:42 -08:00
detached = detached ,
pull = pull ,
workspace_root = workspace_root
2025-12-29 17:05:03 -08:00
)
except Exception as exc :
print ( "install_service_auto error:" , exc )
return False
2026-01-22 00:22:42 -08:00
except Exception as exc :
print ( "install_service_auto error:" , exc )
return False
2025-12-29 17:05:03 -08:00
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
2025-12-29 18:42:02 -08:00
def print_activation_instructions (
repo_root : Path ,
venv_dir : Path ,
venv_py : Path
) -> None :
2025-12-29 17:05:03 -08:00
print ( " \n Activation 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 " \n Direct run without activating: \n { str ( venv_py ) } { str ( repo_root / 'hydrus_client.py' ) } "
)
def detach_kwargs_for_platform ():
kwargs = {}
if os . name == "nt" :
2026-01-22 01:09:09 -08:00
# 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
2025-12-29 17:05:03 -08:00
else :
kwargs [ "start_new_session" ] = True
return kwargs
2025-12-29 18:42:02 -08:00
def find_venv_python ( repo_root : Path ,
venv_arg : Optional [ str ],
venv_name : str ) -> Optional [ Path ]:
2025-12-29 17:05:03 -08:00
# 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 (
2025-12-29 18:42:02 -08:00
[ str ( python_exe ),
"-c" ,
imports ],
2025-12-29 17:05:03 -08:00
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 (
2025-12-29 18:42:02 -08:00
description =
"Run hydrus_client.py using the repo-local venv Python (top-level helper)"
2025-12-29 17:05:03 -08:00
)
p . add_argument (
2025-12-29 18:42:02 -08:00
"--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)"
2025-12-29 17:05:03 -08:00
)
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" ,
2025-12-29 18:42:02 -08:00
help =
"Force re-install dependencies from requirements.txt into the venv (uses --force-reinstall)" ,
2025-12-29 17:05:03 -08:00
)
p . add_argument (
"--verify" ,
action = "store_true" ,
2025-12-29 18:42:02 -08:00
help =
"Verify that packages from requirements.txt are importable in the venv (after install)" ,
2025-12-29 17:05:03 -08:00
)
p . add_argument (
"--headless" ,
action = "store_true" ,
2025-12-29 18:42:02 -08:00
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" ,
2025-12-29 17:05:03 -08:00
)
2026-01-22 00:22:42 -08:00
p . add_argument (
"--pull" ,
action = "store_true" ,
2026-05-26 15:32:01 -07:00
help = "Force a repository update before starting the client" ,
2026-04-01 14:36:36 -07:00
)
p . add_argument (
"--no-update" ,
action = "store_true" ,
help = "Skip the default repository git pull before startup" ,
2026-01-22 00:22:42 -08:00
)
2026-02-04 20:51:54 -08:00
p . add_argument (
"--update-deps" ,
action = "store_true" ,
help = "Update python dependencies to latest compatible versions on startup" ,
)
2025-12-29 17:05:03 -08:00
p . add_argument (
"--gui" ,
action = "store_true" ,
help = "Start the client with the GUI visible (overrides headless/default) " ,
)
p . add_argument (
2025-12-29 18:42:02 -08:00
"--detached" ,
action = "store_true" ,
help = "Start the client and do not wait (detached)"
2025-12-29 17:05:03 -08:00
)
p . add_argument (
"--install-service" ,
action = "store_true" ,
2025-12-29 18:42:02 -08:00
help =
"Install a user-level start-on-boot service/scheduled task for the hydrus client" ,
2025-12-29 17:05:03 -08:00
)
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)" ,
)
2026-01-23 03:48:41 -08:00
p . add_argument (
"--service-user" ,
default = None ,
help = "When installing a system-wide unit as root, optionally run it under this user (default: hydrusnetwork)" ,
)
2025-12-29 17:05:03 -08:00
p . add_argument (
2025-12-29 18:42:02 -08:00
"--cwd" ,
default = None ,
help = "Working directory to start the client in (default: repo root)"
2025-12-29 17:05:03 -08:00
)
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 )
2026-01-21 22:52:52 -08:00
script_dir = Path ( __file__ ) . resolve () . parent
if ( script_dir / "hydrus_client.py" ) . exists ():
workspace_root = script_dir
else :
workspace_root = script_dir . parent
2025-12-29 17:05:03 -08:00
if args . repo_root :
repo_root = Path ( args . repo_root ) . expanduser () . resolve ()
else :
2026-01-21 22:52:52 -08:00
if ( workspace_root / "hydrus_client.py" ) . exists ():
2025-12-29 17:05:03 -08:00
repo_root = workspace_root
2026-01-21 22:52:52 -08:00
else :
candidate = workspace_root / "hydrusnetwork"
if candidate . exists ():
repo_root = candidate
else :
repo_root = workspace_root
2025-12-29 17:05:03 -08:00
2026-04-01 14:36:36 -07:00
repo_updated = False
should_pull_repo = not args . no_update and not ( args . install_service or args . uninstall_service )
if should_pull_repo :
2026-01-22 00:22:42 -08:00
if shutil . which ( "git" ):
if ( repo_root / ".git" ) . exists ():
if not args . quiet :
2026-04-01 14:36:36 -07:00
print ( f "Updating repository via 'git pull --ff-only' in { repo_root } ..." )
2026-01-22 00:22:42 -08:00
try :
2026-01-22 01:09:09 -08:00
k = {}
if os . name == "nt" :
k [ "creationflags" ] = 0x08000000
2026-04-01 14:36:36 -07:00
result = subprocess . run (
[ "git" , "pull" , "--ff-only" ],
cwd = str ( repo_root ),
check = False ,
capture_output = True ,
text = True ,
** k ,
)
pull_output = " \n " . join (
part . strip ()
for part in [ result . stdout or "" , result . stderr or "" ]
if part . strip ()
)
if result . returncode != 0 :
print ( f "Warning: git pull failed: { pull_output or result . returncode } " )
else :
repo_updated = "already up to date" not in pull_output . lower ()
if pull_output and not args . quiet :
print ( pull_output )
2026-01-22 00:22:42 -08:00
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." )
2025-12-29 17:05:03 -08:00
venv_py = find_venv_python ( repo_root , args . venv , args . venv_name )
def _is_running_in_virtualenv () -> bool :
try :
2025-12-29 18:42:02 -08:00
return hasattr ( sys ,
"real_prefix" ) or getattr ( sys ,
"base_prefix" ,
None
) != getattr ( sys ,
"prefix" ,
None )
2025-12-29 17:05:03 -08:00
except Exception :
return False
2026-01-22 01:09:09 -08:00
# Skip heavy verification if we are just installing/uninstalling a service
2026-01-23 14:28:56 -08:00
do_verify = args . verify and not ( args . install_service or args . uninstall_service )
2026-01-22 01:09:09 -08:00
2025-12-29 17:05:03 -08:00
# 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 )
2026-01-22 11:05:40 -08:00
# 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 ):
2025-12-29 17:05:03 -08:00
# 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 )
2026-01-22 01:09:09 -08:00
pkgs = parse_requirements_file ( req ) if req and do_verify else []
2025-12-29 17:05:03 -08:00
check_pkgs = pkgs if pkgs else [ "pyyaml" ]
2026-01-22 01:09:09 -08:00
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
2025-12-29 17:05:03 -08:00
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).
2026-01-22 01:09:09 -08:00
if venv_py and venv_py != cur_py and do_verify :
2025-12-29 17:05:03 -08:00
if not args . quiet :
2026-01-22 01:09:09 -08:00
print ( f "Found venv python: { venv_py } " )
2025-12-29 17:05:03 -08:00
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" ])
2026-01-22 01:09:09 -08:00
# ... logic continues below
2025-12-29 17:05:03 -08:00
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 (
2025-12-29 18:42:02 -08:00
repo_root ,
repo_root / args . venv_name ,
repo_root / args . venv_name
2025-12-29 17:05:03 -08:00
)
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
2026-02-04 20:51:54 -08:00
# 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
2026-04-01 14:36:36 -07:00
if not should_update and ( args . pull or repo_updated ) and not ( args . install_service or args . uninstall_service ):
2026-02-04 20:51:54 -08:00
should_update = True
if args . install_deps or args . reinstall or should_update :
2025-12-29 17:05:03 -08:00
req = find_requirements ( repo_root )
if not req :
print ( "No requirements.txt found; skipping install" )
else :
2026-04-01 14:36:36 -07:00
ok = install_requirements ( venv_py , repo_root , req , reinstall = args . reinstall , upgrade = should_update )
2025-12-29 17:05:03 -08:00
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 :
2025-12-29 18:42:02 -08:00
print (
"Verification failed; see instructions above to re-run installation."
)
2025-12-29 17:05:03 -08:00
# If not installing but user asked to verify, do verification only
2026-02-04 20:51:54 -08:00
if args . verify and not ( args . install_deps or args . reinstall or should_update ):
2025-12-29 17:05:03 -08:00
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
2026-01-22 01:09:09 -08:00
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
2025-12-29 17:05:03 -08:00
2026-01-22 01:09:09 -08:00
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
2025-12-29 17:05:03 -08:00
2026-01-22 01:09:09 -08:00
# Default: print a clear warning and proceed to launch with the repository venv
2026-01-23 14:28:56 -08:00
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."
)
2025-12-29 17:05:03 -08:00
# 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
2026-01-23 03:48:41 -08:00
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"
2025-12-29 17:05:03 -08:00
if args . install_service :
ok = install_service_auto (
2025-12-29 18:42:02 -08:00
args . service_name ,
repo_root ,
venv_py ,
headless = use_headless ,
2026-01-22 00:22:42 -08:00
detached = True ,
pull = args . pull ,
2026-01-23 03:48:41 -08:00
workspace_root = workspace_root ,
service_user = service_user
2025-12-29 17:05:03 -08:00
)
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 :
2026-01-21 22:52:52 -08:00
# Default to GUI for the client launcher
headless = False
2026-01-22 01:09:09 -08:00
# 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
2026-01-21 22:52:52 -08:00
if not args . quiet and is_first_run ( repo_root ):
2025-12-29 17:05:03 -08:00
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" ):
2025-12-29 18:42:02 -08:00
xvfb_cmd = [
"xvfb-run" ,
"--auto-servernum" ,
"--server-args=-screen 0 1024x768x24"
]
2025-12-29 17:05:03 -08:00
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 ()
2025-12-29 18:42:02 -08:00
kwargs . update ({
"cwd" : str ( cwd ),
"env" : env
})
2025-12-29 17:05:03 -08:00
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 :
2026-01-22 12:11:05 -08:00
p = None
2025-12-29 17:05:03 -08:00
try :
2026-01-22 12:11:05 -08:00
# 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.
2026-01-22 01:09:09 -08:00
kwargs = {}
2026-01-22 12:11:05 -08:00
if os . name == "nt" and headless and "pythonw.exe" not in str ( venv_py ) . lower ():
2026-01-22 01:09:09 -08:00
kwargs [ "creationflags" ] = 0x08000000
2026-01-22 12:11:05 -08:00
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 } " )
2025-12-29 17:05:03 -08:00
return 5
2026-01-22 12:11:05 -08:00
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
2025-12-29 17:05:03 -08:00
if __name__ == "__main__" :
raise SystemExit ( main ())