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.5" 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 "), }