494 lines
17 KiB
Python
494 lines
17 KiB
Python
import locale
|
|
import gettext
|
|
import tkinter as tk
|
|
from pathlib import Path
|
|
from tkinter import ttk
|
|
import os
|
|
import subprocess
|
|
import stat
|
|
from network import GiteaUpdate
|
|
|
|
|
|
class LXToolsAppConfig:
|
|
VERSION = "1.1.4"
|
|
APP_NAME = "Lunix Tools Installer"
|
|
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 = Path("/usr/share/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", "update", "&&", "apt", "install", "-y", "python3-tk"],
|
|
"Debian": ["apt", "update", "&&", "apt", "install", "-y", "python3-tk"],
|
|
"Linux Mint": ["apt", "update", "&&", "apt", "install", "-y", "python3-tk"],
|
|
"Pop!_OS": ["apt", "update", "&&", "apt", "install", "-y", "python3-tk"],
|
|
"Fedora": ["dnf", "install", "-y", "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", "python314-tk"],
|
|
"SUSE Leap": ["zypper", "install", "-y", "python312-tk"],
|
|
}
|
|
|
|
@staticmethod
|
|
def setup_translations():
|
|
"""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
|
|
|
|
|
|
# Initialize translations
|
|
_ = LXToolsAppConfig.setup_translations()
|
|
|
|
|
|
class OSDetector:
|
|
@staticmethod
|
|
def detect_os():
|
|
"""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 check_tkinter_available():
|
|
"""Check if tkinter is available"""
|
|
try:
|
|
import tkinter
|
|
|
|
return True
|
|
except ImportError:
|
|
return False
|
|
|
|
@staticmethod
|
|
def install_tkinter():
|
|
"""Install tkinter based on detected OS"""
|
|
detected_os = OSDetector.detect_os()
|
|
|
|
if detected_os in LXToolsAppConfig.TKINTER_INSTALL_COMMANDS:
|
|
commands = LXToolsAppConfig.TKINTER_INSTALL_COMMANDS[detected_os]
|
|
|
|
print(f"Installing tkinter for {detected_os}...")
|
|
print(_(f"Command: {' '.join(commands)}"))
|
|
|
|
try:
|
|
# Use pkexec for privilege escalation
|
|
full_command = ["pkexec", "bash", "-c", " ".join(commands)]
|
|
result = subprocess.run(
|
|
full_command, capture_output=True, text=True, timeout=300
|
|
)
|
|
|
|
if result.returncode == 0:
|
|
print(_("TKinter installation completed successfully!"))
|
|
return True
|
|
else:
|
|
print(_(f"TKinter installation failed: {result.stderr}"))
|
|
return False
|
|
|
|
except subprocess.TimeoutExpired:
|
|
print(_("TKinter installation timed out"))
|
|
return False
|
|
except Exception as e:
|
|
print(_(f"Error installing tkinter: {e}"))
|
|
return False
|
|
else:
|
|
print(_(f"No tkinter installation command defined for {detected_os}"))
|
|
return False
|
|
|
|
|
|
class Theme:
|
|
@staticmethod
|
|
def apply_light_theme(root):
|
|
"""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):
|
|
"""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):
|
|
"""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):
|
|
"""Copy directory using pkexec"""
|
|
subprocess.run(["pkexec", "cp", "-r", src, dest], check=True)
|
|
|
|
@staticmethod
|
|
def remove_file(path):
|
|
"""Remove file using pkexec"""
|
|
subprocess.run(["pkexec", "rm", "-f", path], check=False)
|
|
|
|
@staticmethod
|
|
def remove_directory(path):
|
|
"""Remove directory using pkexec"""
|
|
subprocess.run(["pkexec", "rm", "-rf", path], check=False)
|
|
|
|
@staticmethod
|
|
def create_symlink(target, link_name):
|
|
"""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):
|
|
"""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):
|
|
"""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"Failed to load image from {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):
|
|
"""Get project information by key"""
|
|
return self.projects.get(project_key)
|
|
|
|
def get_all_projects(self):
|
|
"""Get all project configurations"""
|
|
return self.projects
|
|
|
|
def is_installed(self, project_key):
|
|
"""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(
|
|
"/usr/local/share/shared_libs/logviewer.py"
|
|
)
|
|
executable_is_executable = False
|
|
|
|
if executable_exists:
|
|
try:
|
|
# Check if file is executable
|
|
file_stat = os.stat("/usr/local/share/shared_libs/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(_("LogViewer installation check:"))
|
|
print(_(f" Symlink exists: {symlink_exists}"))
|
|
print(_(f" Executable exists: {executable_exists}"))
|
|
print(_(f" Is executable: {executable_is_executable}"))
|
|
print(_(f" Final result: {is_installed}"))
|
|
|
|
return is_installed
|
|
|
|
return False
|
|
|
|
def get_installed_version(self, project_key):
|
|
"""Get installed version from config file"""
|
|
try:
|
|
if project_key == "wirepy":
|
|
config_file = "/usr/local/share/shared_libs/wp_app_config.py"
|
|
elif project_key == "logviewer":
|
|
config_file = "/usr/local/share/shared_libs/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"Error getting version for {project_key}: {e}"))
|
|
return "Unknown"
|
|
|
|
def get_latest_version(self, project_key):
|
|
"""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):
|
|
"""Check if other apps are still installed"""
|
|
return any(
|
|
self.is_installed(key) for key in self.projects.keys() if key != exclude_key
|
|
)
|
|
|
|
|
|
class Center:
|
|
@staticmethod
|
|
def center_window_cross_platform(window, width, height):
|
|
"""
|
|
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}")
|