Files
lxtools_installer/manager.py

692 lines
24 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import locale
import gettext
import tkinter as tk
from tkinter import ttk
from pathlib import Path
import os
import sys
import shutil
import subprocess
import stat
from network import GiteaUpdate
class Detector:
@staticmethod
def get_os() -> str:
"""Detect operating system using ordered list"""
try:
with open("/etc/os-release", "r") as f:
content = f.read().lower()
# Check each OS in order (specific first)
for keyword, os_name in LXToolsAppConfig.OS_DETECTION:
if keyword in content:
return os_name
return "Unknown System"
except FileNotFoundError:
return "File not found"
@staticmethod
def get_host_python_version() -> str:
try:
result = subprocess.run(
["python3", "--version"], capture_output=True, text=True, check=True
)
version_str = result.stdout.strip().replace("Python ", "")
return version_str[:4] # example "3.13"
except Exception:
print("Python not found")
return None
@staticmethod
def get_user_gt_1() -> bool:
"""This method may be required for the future if the number of users"""
path = Path("/home")
user_directories = [
entry
for entry in path.iterdir()
if entry.is_dir() and entry.name != "root" and entry.name != "lost+found"
]
# Count the number of user directories
numbers = len(user_directories)
if not numbers > 1:
return True
else:
return False
@staticmethod
def get_wget() -> bool:
"""Check if wget is installed"""
result = subprocess.run(
["which", "wget"], capture_output=True, text=True, check=False
)
if result.returncode == 0:
return True
else:
return False
@staticmethod
def get_unzip() -> bool:
"""Check if wget is installed"""
result = subprocess.run(
["which", "unzip"], capture_output=True, text=True, check=False
)
if result.returncode == 0:
return True
else:
return False
@staticmethod
def get_requests() -> bool:
"""Check if requests is installed"""
result = subprocess.run(
["pacman", "-Qs", "python-requests"],
capture_output=True,
text=True,
check=False,
)
if result.returncode == 0:
return True
else:
return False
class Theme:
@staticmethod
def apply_light_theme(root) -> bool:
"""Apply light theme"""
try:
theme_dir = LXToolsAppConfig.THEMES_DIR
water_theme_path = os.path.join(theme_dir, "water.tcl")
if os.path.exists(water_theme_path):
try:
root.tk.call("source", water_theme_path)
root.tk.call("set_theme", "light")
return True
except tk.TclError:
pass
# System theme fallback
try:
style = ttk.Style()
if "clam" in style.theme_names():
style.theme_use("clam")
return True
except:
pass
except Exception:
pass
return False
class System:
@staticmethod
def create_directories(directories) -> None:
"""Create system directories using pkexec"""
for directory in directories:
subprocess.run(["pkexec", "mkdir", "-p", directory], check=True)
@staticmethod
def copy_file(src, dest, make_executable=False) -> None:
"""Copy file using pkexec"""
subprocess.run(["pkexec", "cp", src, dest], check=True)
if make_executable:
subprocess.run(["pkexec", "chmod", "755", dest], check=True)
@staticmethod
def copy_directory(src, dest) -> None:
"""Copy directory using pkexec"""
subprocess.run(["pkexec", "cp", "-r", src, dest], check=True)
@staticmethod
def remove_file(path) -> None:
"""Remove file using pkexec"""
subprocess.run(["pkexec", "rm", "-f", path], check=False)
@staticmethod
def remove_directory(path) -> None:
"""Remove directory using pkexec"""
subprocess.run(["pkexec", "rm", "-rf", path], check=False)
@staticmethod
def create_symlink(target, link_name) -> None:
"""Create symbolic link using pkexec"""
subprocess.run(["pkexec", "rm", "-f", link_name], check=False)
subprocess.run(["pkexec", "ln", "-sf", target, link_name], check=True)
@staticmethod
def create_ssl_key(pem_file) -> bool:
"""Create SSL key using pkexec"""
try:
subprocess.run(
["pkexec", "openssl", "genrsa", "-out", pem_file, "4096"], check=True
)
subprocess.run(["pkexec", "chmod", "600", pem_file], check=True)
return True
except subprocess.CalledProcessError:
return False
class Image:
def __init__(self):
self.images = {}
def load_image(self, image_key, fallback_paths=None) -> None | tk.PhotoImage:
"""Load PNG image using tk.PhotoImage with fallback options"""
if image_key in self.images:
return self.images[image_key]
# Define image paths based on key
image_paths = {
"app_icon": [
"./lx-icons/48/wg_vpn.png",
"/usr/share/icons/lx-icons/48/wg_vpn.png",
],
"download_icon": [
"./lx-icons/32/download.png",
"/usr/share/icons/lx-icons/32/download.png",
],
"download_error_icon": [
"./lx-icons/32/download_error.png",
"/usr/share/icons/lx-icons/32/download_error.png",
],
"success_icon": [
"./lx-icons/32/download.png",
"/usr/share/icons/lx-icons/32/download.png",
],
"icon_vpn": [
"./lx-icons/48/wg_vpn.png",
"/usr/share/icons/lx-icons/48/wg_vpn.png",
],
"icon_log": [
"./lx-icons/48/log.png",
"/usr/share/icons/lx-icons/48/log.png",
],
}
# Get paths to try
paths_to_try = image_paths.get(image_key, [])
# Add fallback paths if provided
if fallback_paths:
paths_to_try.extend(fallback_paths)
# Try to load image from paths
for path in paths_to_try:
try:
if os.path.exists(path):
photo = tk.PhotoImage(file=path)
self.images[image_key] = photo
return photo
except tk.TclError as e:
print(f"{LocaleStrings.MSGP["fail_load_image"]}{path}: {e}")
continue
# Return None if no image found
return None
class AppManager:
def __init__(self):
self.projects = LXToolsAppConfig.PROJECTS
def get_project_info(self, project_key) -> dict | None:
"""Get project information by key"""
return self.projects.get(project_key)
def get_all_projects(self) -> dict:
"""Get all project configurations"""
return self.projects
def is_installed(self, project_key) -> bool:
detected_os = Detector.get_os()
"""Check if project is installed with better detection"""
if project_key == "wirepy":
# Check for wirepy symlink
return os.path.exists("/usr/local/bin/wirepy") and os.path.islink(
"/usr/local/bin/wirepy"
)
elif project_key == "logviewer":
# Check for logviewer symlink AND executable file
symlink_exists = os.path.exists("/usr/local/bin/logviewer")
executable_exists = os.path.exists(
f"{LXToolsAppConfig.SHARED_LIBS_DESTINATION[detected_os]}/logviewer.py"
)
executable_is_executable = False
if executable_exists:
try:
# Check if file is executable
file_stat = os.stat(
f"{LXToolsAppConfig.SHARED_LIBS_DESTINATION[detected_os]}/logviewer.py"
)
executable_is_executable = bool(file_stat.st_mode & stat.S_IEXEC)
except:
executable_is_executable = False
# LogViewer is installed if symlink exists AND executable file exists AND is executable
is_installed = (
symlink_exists and executable_exists and executable_is_executable
)
# Debug logging
print(LocaleStrings.MSGP["logviewer_check"])
print(f"{LocaleStrings.MSGP["symlink_exist"]}{symlink_exists}")
print(f"{LocaleStrings.MSGP["executable_exist"]}{executable_exists}")
print(f"{LocaleStrings.MSGP["is_executable"]}{executable_is_executable}")
print(f"{LocaleStrings.MSGP["final_result"]}{is_installed}")
return is_installed
return False
def get_installed_version(self, project_key) -> str:
detected_os = Detector.get_os()
"""Get installed version from config file"""
try:
if project_key == "wirepy":
config_file = f"{LXToolsAppConfig.SHARED_LIBS_DESTINATION[detected_os]}/wp_app_config.py"
elif project_key == "logviewer":
config_file = f"{LXToolsAppConfig.SHARED_LIBS_DESTINATION[detected_os]}/logview_app_config.py"
else:
return "Unknown"
if os.path.exists(config_file):
with open(config_file, "r") as f:
content = f.read()
for line in content.split("\n"):
if "VERSION" in line and "=" in line:
version = line.split("=")[1].strip().strip("\"'")
return version
return "Unknown"
except Exception as e:
print(f"{LocaleStrings.MSGP["get_version_error"]}{project_key}: {e}")
return "Unknown"
def get_latest_version(self, project_key) -> str:
"""Get latest version from API"""
project_info = self.get_project_info(project_key)
if not project_info:
return "Unknown"
return GiteaUpdate.api_down(project_info["api_url"])
def check_other_apps_installed(self, exclude_key) -> bool:
"""Check if other apps are still installed"""
return any(
self.is_installed(key) for key in self.projects.keys() if key != exclude_key
)
class LxTools:
@staticmethod
def center_window_cross_platform(window, width, height) -> None:
"""
Centers a window on the primary monitor in a way that works on both X11 and Wayland
Args:
window: The tkinter window to center
width: Window width
height: Window height
"""
# Calculate the position before showing the window
# First attempt: Try to use GDK if available (works on both X11 and Wayland)
try:
import gi
gi.require_version("Gdk", "3.0")
from gi.repository import Gdk
display = Gdk.Display.get_default()
monitor = display.get_primary_monitor() or display.get_monitor(0)
geometry = monitor.get_geometry()
scale_factor = monitor.get_scale_factor()
# Calculate center position on the primary monitor
x = geometry.x + (geometry.width - width // scale_factor) // 2
y = geometry.y + (geometry.height - height // scale_factor) // 2
# Set window geometry
window.geometry(f"{width}x{height}+{x}+{y}")
return
except (ImportError, AttributeError):
pass
# Second attempt: Try xrandr for X11
try:
import subprocess
output = subprocess.check_output(
["xrandr", "--query"], universal_newlines=True
)
# Parse the output to find the primary monitor
primary_info = None
for line in output.splitlines():
if "primary" in line:
parts = line.split()
for part in parts:
if "x" in part and "+" in part:
primary_info = part
break
break
if primary_info:
# Parse the geometry: WIDTH x HEIGHT+X+Y
geometry = primary_info.split("+")
dimensions = geometry[0].split("x")
primary_width = int(dimensions[0])
primary_height = int(dimensions[1])
primary_x = int(geometry[1])
primary_y = int(geometry[2])
# Calculate center position on the primary monitor
x = primary_x + (primary_width - width) // 2
y = primary_y + (primary_height - height) // 2
# Set window geometry
window.geometry(f"{width}x{height}+{x}+{y}")
return
except (ImportError, IndexError, ValueError):
pass
# Final fallback: Use standard Tkinter method
screen_width = window.winfo_screenwidth()
screen_height = window.winfo_screenheight()
# Try to make an educated guess for multi-monitor setups
# If screen width is much larger than height, assume multiple monitors side by side
if (
screen_width > screen_height * 1.8
): # Heuristic for detecting multiple monitors
# Assume the primary monitor is on the left half
screen_width = screen_width // 2
x = (screen_width - width) // 2
y = (screen_height - height) // 2
window.geometry(f"{width}x{height}+{x}+{y}")
class LXToolsAppConfig:
@staticmethod
def extract_data_files() -> None:
if getattr(sys, "_MEIPASS", None) is not None:
# Liste der Quellordner (entspricht dem "datas"-Eintrag in lxtools_installer.spec)
source_dirs = [
os.path.join(sys._MEIPASS, "locale"), # für locale/...
os.path.join(sys._MEIPASS, "TK-Themes"), # für TK-Themes/...
os.path.join(sys._MEIPASS, "lx-icons"), # für lx-icons/...
]
target_dir = os.path.abspath(
os.getcwd()
) # Zielverzeichnis: aktueller Ordner
for source_dir in source_dirs:
group_name = os.path.basename(
source_dir
) # Erhält den Gruppen-Name (z.B. 'locale', 'TK-Themes')
for root, dirs, files in os.walk(source_dir):
for file in files:
src_path = os.path.join(root, file)
# Relativer Pfad innerhalb des Quellordners
rel_path_from_source_root = os.path.relpath(
src_path, source_dir
)
# Ziel-Pfad unter dem Gruppen-Ordner im aktuellen Verzeichnis
dst_path = os.path.join(
target_dir, group_name, rel_path_from_source_root
)
os.makedirs(os.path.dirname(dst_path), exist_ok=True)
shutil.copy2(src_path, dst_path)
# Set the SSL certificate file path by start as appimage
os.environ["SSL_CERT_FILE"] = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "certs", "cacert.pem"
)
@staticmethod
def setup_translations() -> gettext.gettext:
"""Initialize translations and set the translation function"""
try:
locale.bindtextdomain(
LXToolsAppConfig.APP_NAME, LXToolsAppConfig.LOCALE_DIR
)
gettext.bindtextdomain(
LXToolsAppConfig.APP_NAME, LXToolsAppConfig.LOCALE_DIR
)
gettext.textdomain(LXToolsAppConfig.APP_NAME)
except:
pass
return gettext.gettext
VERSION = "1.1.6"
APP_NAME = "lxtoolsinstaller"
WINDOW_WIDTH = 450
WINDOW_HEIGHT = 580
# Working directory
WORK_DIR = os.getcwd()
ICONS_DIR = os.path.join(WORK_DIR, "lx-icons")
THEMES_DIR = os.path.join(WORK_DIR, "TK-Themes")
# Locale settings
LOCALE_DIR = "./locale/"
# Download URLs
WIREPY_URL = "https://git.ilunix.de/punix/Wire-Py/archive/main.zip"
SHARED_LIBS_URL = "https://git.ilunix.de/punix/shared_libs/archive/main.zip"
# API URLs for version checking
WIREPY_API_URL = "https://git.ilunix.de/api/v1/repos/punix/Wire-Py/releases"
SHARED_LIBS_API_URL = (
"https://git.ilunix.de/api/v1/repos/punix/shared_libs/releases"
)
# Project configurations
PROJECTS = {
"wirepy": {
"name": "Wire-Py",
"description": "WireGuard VPN Manager with GUI",
"download_url": WIREPY_URL,
"api_url": WIREPY_API_URL,
"icon_key": "icon_vpn",
"main_executable": "wirepy.py",
"symlink_name": "wirepy",
"config_file": "wp_app_config.py",
"desktop_file": "Wire-Py.desktop",
"policy_file": "org.sslcrypt.policy",
"requires_ssl": True,
},
"logviewer": {
"name": "LogViewer",
"description": "System Log Viewer with GUI",
"download_url": SHARED_LIBS_URL,
"api_url": SHARED_LIBS_API_URL,
"icon_key": "icon_log",
"main_executable": "logviewer.py",
"symlink_name": "logviewer",
"config_file": "logview_app_config.py",
"desktop_file": "LogViewer.desktop",
"policy_file": None,
"requires_ssl": False,
},
}
# OS Detection List (order matters - specific first, generic last)
OS_DETECTION = [
("mint", "Linux Mint"),
("pop", "Pop!_OS"),
("manjaro", "Manjaro"),
("garuda", "Garuda Linux"),
("endeavouros", "EndeavourOS"),
("fedora", "Fedora"),
("tumbleweed", "SUSE Tumbleweed"),
("leap", "SUSE Leap"),
("arch", "Arch Linux"),
("ubuntu", "Ubuntu"),
("debian", "Debian"),
]
# Package manager commands for TKinter installation
TKINTER_INSTALL_COMMANDS = {
"Ubuntu": "apt install -y python3-tk",
"Debian": "apt install -y python3-tk",
"Linux Mint": "apt install -y python3-tk",
"Pop!_OS": "apt install -y python3-tk",
"Fedora": "dnf install -y python3-tkinter",
"Arch Linux": "pacman -S --noconfirm tk",
"Manjaro": "pacman -S --noconfirm tk",
"Garuda Linux": "pacman -S --noconfirm tk",
"EndeavourOS": "pacman -S --noconfirm tk",
"SUSE Tumbleweed": "zypper install -y python3-tk",
"SUSE Leap": "zypper install -y python3-tk",
}
SHARED_LIBS_DESTINATION = {
"Ubuntu": "/usr/lib/python3/dist-packages/shared_libs",
"Debian": "/usr/lib/python3/dist-packages/shared_libs",
"Linux Mint": "/usr/lib/python3/dist-packages/shared_libs",
"Pop!_OS": "/usr/lib/python3/dist-packages/shared_libs",
"Fedora": f"/usr/lib64/python{Detector.get_host_python_version()}/site-packages/shared_libs",
"Arch Linux": f"/usr/lib64/python{Detector.get_host_python_version()}/site-packages/shared_libs",
"Manjaro": f"/usr/lib64/python{Detector.get_host_python_version()}/site-packages/shared_libs",
"Garuda Linux": f"/usr/lib64/python{Detector.get_host_python_version()}/site-packages/shared_libs",
"EndeavourOS": f"/usr/lib64/python{Detector.get_host_python_version()}/site-packages/shared_libs",
"SUSE Tumbleweed": f"/usr/lib64/python{Detector.get_host_python_version()}/site-packages/shared_libs",
"SUSE Leap": f"/usr/lib64/python{Detector.get_host_python_version()}/site-packages/shared_libs",
}
LXToolsAppConfig.extract_data_files()
# Initialize translations
_ = LXToolsAppConfig.setup_translations()
class LocaleStrings:
MSGI = {
"refresh_and_check": _("Refreshing status and checking versions..."),
"start_install": _("Starting installation of "),
"install": _("Installing "),
"install_success": _(" installation successfully!"),
"install_failed": _("Installation failed: "),
"install_create": _("Created install script: "),
"install_script_failed": _("Installation script failed: "),
"install_timeout": _("Installation timed out"),
"installed": _("Installed "),
}
MSGU = {
"uninstall": _("Uninstalling "),
"uninstall_success": _(" uninstalled successfully!"),
"uninstall_failed": _("Uninstallation failed: "),
"uninstall_create": _("Created uninstall script: "),
"uninstall_script_failed": _("Uninstallation script failed: "),
"uninstall_timeout": _("Uninstallation timed out"),
}
# MSGO = Other messages
MSGO = {
"unknown_project": _("Unknown project: "),
"not_install": _(" is not installed."),
"download_from": _("Downloading from "),
"extract_files": _("Extracting files..."),
"download_failed": _("Download failed: "),
"head_string2": _("System: "),
"head_string3": _("Linux App Installer"),
"ready": _("Ready for installation"),
"no_internet": _("No internet connection"),
"repo_unavailable": _("Repository unavailable"),
"system_check": _("System checking..."),
"applications": _("Applications"),
"progress": _("Progress"),
"refresh2": _("Status refresh completed"),
"python_check": _("Python not installed"),
}
# MSGC = Strings on Cards
MSGC = {
"checking": _("Checking..."),
"version_check": _("Version: Checking..."),
"latest": _("Latest: "),
"update_available": _("Update available "),
"up_to_date": _("Up to date"),
"latest_unknown": _("Latest unknown"),
"could_not_check": _("Could not check latest version"),
"check_last_failed": _("Latest: Check failed"),
"version_check_failed": _("Version check failed"),
"not_installed": _("Not installed"),
"available": _("Available "),
"available_unknown": _("Available unknown"),
"available_ckeck_failed": _("Available: Check failed"),
}
# MSGL = Strings on Logmessages
MSGL = {
"selected_app": _("Selected project: "),
"log_name": _("Installation Log"),
"work_dir": _("Working directory: "),
"icons_dir": _("Icons directory: "),
"detected_os": _("Detected OS: "),
"log_cleared": _("Log cleared"),
"working_dir": _("Working directory: "),
"user_interuppt": _("\nApplication interrupted by user."),
"fatal_error": _("Fatal error: "),
"fatal_app_error": _("Fatal Error Application failed to start: "),
}
# MSGB = Strings on Buttons
MSGB = {
"clear_log": _("Clear Log"),
"install": _("Install/Update"),
"uninstall": _("Uninstall"),
"refresh": _("Refresh Status"),
}
# MSGM = String on MessagDialogs
MSGM = {
"please_select": _("Please select a project to install."),
"network_error": _(
"No internet connection available.\nPlease check your network connection.",
),
"repo_error": _(
"Cannot access repository.\nPlease try again later.",
),
"has_success_update": _("has been successfully installed/updated."),
"please_select_uninstall": _("Please select a project to uninstall."),
}
# MSGP = Others print strings
MSGP = {
"tk_install": _("Installing tkinter for )"),
"command_string": _("Command: "),
"tk_success": _("TKinter installation completed successfully!"),
"tk_failed": _("TKinter installation failed: "),
"tk_timeout": _("TKinter installation timed out"),
"tk_install_error": _("Error installing tkinter: "),
"tk_command_error": _("No tkinter installation command defined for "),
"fail_load_image": _("Failed to load image from "),
"logviewer_check": _("LogViewer installation check:"),
"symlink_exist": _(" Symlink exists: "),
"executable_exist": _(" Executable exists: "),
"is_executable": _(" Is executable: "),
"final_result": _(" Final result: "),
"get_version_error": _("Error getting version for "),
}