277 lines
8.3 KiB
Python
277 lines
8.3 KiB
Python
import os
|
|
import subprocess
|
|
import sys
|
|
from importlib.util import find_spec
|
|
from pathlib import Path
|
|
from tkinter import TclError
|
|
|
|
BASE_DIR = Path(__file__).resolve().parent
|
|
REQUIREMENTS_FILE = BASE_DIR / "requirements.txt"
|
|
PROJECT_VENV_DIR = BASE_DIR / ".runtime-venv"
|
|
|
|
REQUIRED_IMPORTS = {
|
|
"customtkinter": "customtkinter",
|
|
"cryptography": "cryptography",
|
|
"pyserial": "serial",
|
|
"arabic-reshaper": "arabic_reshaper",
|
|
"python-bidi": "bidi",
|
|
}
|
|
|
|
|
|
def _project_venv_python() -> Path:
|
|
if os.name == "nt":
|
|
return PROJECT_VENV_DIR / "Scripts" / "python.exe"
|
|
return PROJECT_VENV_DIR / "bin" / "python"
|
|
|
|
|
|
def _running_inside_virtualenv() -> bool:
|
|
return sys.prefix != getattr(sys, "base_prefix", sys.prefix) or hasattr(sys, "real_prefix")
|
|
|
|
|
|
def _missing_requirements() -> list[str]:
|
|
missing = []
|
|
for package_name, import_name in REQUIRED_IMPORTS.items():
|
|
if find_spec(import_name) is None:
|
|
missing.append(package_name)
|
|
return missing
|
|
|
|
|
|
def _missing_requirements_for_python(python_executable: str) -> tuple[list[str] | None, str | None]:
|
|
check_script = "\n".join(
|
|
[
|
|
"from importlib.util import find_spec",
|
|
f"required = {REQUIRED_IMPORTS!r}",
|
|
"missing = [name for name, import_name in required.items() if find_spec(import_name) is None]",
|
|
"print('\\n'.join(missing))",
|
|
]
|
|
)
|
|
ok, output = _run_subprocess(
|
|
[python_executable, "-c", check_script],
|
|
"Checking project virtual environment",
|
|
)
|
|
if not ok:
|
|
return None, output
|
|
return [line.strip() for line in output.splitlines() if line.strip()], None
|
|
|
|
|
|
def _run_subprocess(command: list[str], description: str) -> tuple[bool, str]:
|
|
try:
|
|
completed = subprocess.run(
|
|
command,
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
except Exception as exc:
|
|
return False, f"{description} failed to start: {exc}"
|
|
|
|
if completed.returncode == 0:
|
|
return True, completed.stdout.strip()
|
|
|
|
details = completed.stderr.strip() or completed.stdout.strip() or "Unknown error"
|
|
return False, f"{description} failed: {details}"
|
|
|
|
|
|
def _ensure_pip_available(python_executable: str) -> tuple[bool, str | None]:
|
|
ok, message = _run_subprocess(
|
|
[python_executable, "-m", "pip", "--version"],
|
|
"Checking pip",
|
|
)
|
|
if ok:
|
|
return True, None
|
|
|
|
ok, ensure_message = _run_subprocess(
|
|
[python_executable, "-m", "ensurepip", "--upgrade"],
|
|
"Bootstrapping pip",
|
|
)
|
|
if ok:
|
|
return True, None
|
|
return False, ensure_message or message
|
|
|
|
|
|
def _install_requirements_with_python(python_executable: str) -> tuple[bool, str | None]:
|
|
if not REQUIREMENTS_FILE.exists():
|
|
return False, f"Requirements file not found: {REQUIREMENTS_FILE}"
|
|
|
|
pip_ready, pip_message = _ensure_pip_available(python_executable)
|
|
if not pip_ready:
|
|
return False, pip_message
|
|
|
|
print("Installing missing Python packages...", flush=True)
|
|
ok, message = _run_subprocess(
|
|
[
|
|
python_executable,
|
|
"-m",
|
|
"pip",
|
|
"install",
|
|
"--disable-pip-version-check",
|
|
"-r",
|
|
str(REQUIREMENTS_FILE),
|
|
],
|
|
"Installing requirements",
|
|
)
|
|
if not ok:
|
|
return False, message
|
|
return True, None
|
|
|
|
|
|
def _create_project_venv() -> tuple[bool, str | None]:
|
|
venv_python = _project_venv_python()
|
|
if venv_python.exists():
|
|
return True, None
|
|
|
|
print(f"Creating local virtual environment in {PROJECT_VENV_DIR}...", flush=True)
|
|
ok, message = _run_subprocess(
|
|
[sys.executable, "-m", "venv", str(PROJECT_VENV_DIR)],
|
|
"Creating virtual environment",
|
|
)
|
|
if ok and venv_python.exists():
|
|
return True, None
|
|
|
|
help_message = "\n".join(
|
|
[
|
|
message or "Creating virtual environment failed.",
|
|
"",
|
|
"On Raspberry Pi / Debian, install venv support first:",
|
|
" sudo apt install python3-venv python3-full",
|
|
]
|
|
)
|
|
return False, help_message
|
|
|
|
|
|
def _restart_inside_project_venv() -> tuple[bool, str | None]:
|
|
venv_python = _project_venv_python()
|
|
if not venv_python.exists():
|
|
return False, f"Virtual environment Python was not found: {venv_python}"
|
|
|
|
print("Restarting app inside the local virtual environment...", flush=True)
|
|
argv = [str(venv_python), str(BASE_DIR / "main.py"), *sys.argv[1:]]
|
|
env = os.environ.copy()
|
|
env["SECURE_SMS_RUNTIME_VENV"] = str(PROJECT_VENV_DIR)
|
|
try:
|
|
os.execve(str(venv_python), argv, env)
|
|
except Exception as exc:
|
|
return False, f"Restarting inside virtual environment failed: {exc}"
|
|
|
|
return True, None
|
|
|
|
|
|
def _ensure_runtime_dependencies() -> tuple[bool, str | None]:
|
|
missing = _missing_requirements()
|
|
if not missing:
|
|
return True, None
|
|
|
|
print(f"Missing packages detected: {', '.join(missing)}", flush=True)
|
|
|
|
if _running_inside_virtualenv():
|
|
ok, message = _install_requirements_with_python(sys.executable)
|
|
if not ok:
|
|
return False, message
|
|
else:
|
|
venv_python = _project_venv_python()
|
|
if venv_python.exists():
|
|
venv_missing, venv_message = _missing_requirements_for_python(str(venv_python))
|
|
if venv_message:
|
|
return False, venv_message
|
|
if not venv_missing:
|
|
ok, message = _restart_inside_project_venv()
|
|
if not ok:
|
|
return False, message
|
|
return False, "Unexpected bootstrap state while switching to the local virtual environment."
|
|
|
|
ok, message = _create_project_venv()
|
|
if not ok:
|
|
return False, message
|
|
|
|
ok, message = _install_requirements_with_python(str(venv_python))
|
|
if not ok:
|
|
return False, message
|
|
|
|
ok, message = _restart_inside_project_venv()
|
|
if not ok:
|
|
return False, message
|
|
return False, "Unexpected bootstrap state while switching to the local virtual environment."
|
|
|
|
remaining = _missing_requirements()
|
|
if remaining:
|
|
return False, f"Packages are still missing after installation: {', '.join(remaining)}"
|
|
|
|
return True, None
|
|
|
|
|
|
def _prepare_linux_display() -> tuple[bool, str | None]:
|
|
if not sys.platform.startswith("linux"):
|
|
return True, None
|
|
|
|
display = os.environ.get("DISPLAY")
|
|
if display:
|
|
return True, None
|
|
|
|
x11_socket = Path("/tmp/.X11-unix/X0")
|
|
if x11_socket.exists():
|
|
os.environ["DISPLAY"] = ":0"
|
|
return True, None
|
|
|
|
message = "\n".join(
|
|
[
|
|
"No GUI display was found for Tkinter.",
|
|
"This app needs a graphical desktop session on Raspberry Pi.",
|
|
"",
|
|
"If you are on the Pi screen, start the desktop first and then run the app.",
|
|
"If you run it from SSH/systemd, usually you need:",
|
|
" DISPLAY=:0",
|
|
" XAUTHORITY=/home/pars/.Xauthority",
|
|
"",
|
|
"Example:",
|
|
" export DISPLAY=:0",
|
|
" export XAUTHORITY=/home/pars/.Xauthority",
|
|
" python3 main.py",
|
|
]
|
|
)
|
|
return False, message
|
|
|
|
|
|
def _display_error_message(exc: Exception) -> str:
|
|
return "\n".join(
|
|
[
|
|
f"GUI startup failed: {exc}",
|
|
"",
|
|
"Tkinter could not connect to the Raspberry Pi graphical session.",
|
|
"Run the app from the Pi desktop session or set DISPLAY/XAUTHORITY correctly.",
|
|
"",
|
|
"Typical Raspberry Pi values:",
|
|
" DISPLAY=:0",
|
|
" XAUTHORITY=/home/pars/.Xauthority",
|
|
]
|
|
)
|
|
|
|
|
|
def main():
|
|
deps_ready, deps_message = _ensure_runtime_dependencies()
|
|
if not deps_ready:
|
|
print(deps_message, file=sys.stderr)
|
|
return 1
|
|
|
|
ready, message = _prepare_linux_display()
|
|
if not ready:
|
|
print(message, file=sys.stderr)
|
|
return 1
|
|
|
|
try:
|
|
from secure_sms.controller import AppController
|
|
from secure_sms.ui import SecureSmsApp
|
|
|
|
controller = AppController()
|
|
app = SecureSmsApp(controller)
|
|
controller.bind_ui(app)
|
|
app.protocol("WM_DELETE_WINDOW", controller.shutdown)
|
|
app.mainloop()
|
|
return 0
|
|
except TclError as exc:
|
|
print(_display_error_message(exc), file=sys.stderr)
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|