import os import sys import platform import hashlib import json import requests import tkinter as tk from tkinter import filedialog, messagebox, ttk from pathlib import Path import threading import subprocess import re # --- КОНФИГУРАЦИЯ --- APP_VERSION = "v1.1.6" # Укажите здесь вашу текущую версию UPDATE_API_URL = "https://git.borderban.ru/api/v1/repos/BorderBan/client-py/releases/latest" SERVER_URL = "https://server1.borderban.ru/mods/" CONFIG_FILE = Path.home() / ".factorio_sync_config.json" def get_mod_base_name(filename): # Пытаемся отсечь версию мода (обычно ИмяМода_1.0.0.zip) match = re.match(r"^(.+)_(\d+\.\d+\.\d+)\.zip$", filename) if match: return match.group(1) if "_" in filename and filename.endswith(".zip"): return filename.rsplit("_", 1)[0] return filename def get_file_hash(filepath): sha256_hash = hashlib.sha256() with open(filepath, "rb") as f: for byte_block in iter(lambda: f.read(4096), b""): sha256_hash.update(byte_block) return sha256_hash.hexdigest() def check_and_update(root): # Выполняем автообновление только если программа запущена как скомпилированный бинарник if not getattr(sys, 'frozen', False): return exe_path = sys.executable old_exe_path = exe_path + ".old" # Очистка мусора: удаляем старый файл, оставшийся от предыдущего обновления if os.path.exists(old_exe_path): try: os.remove(old_exe_path) except Exception: pass try: # Получаем данные о последнем релизе response = requests.get(UPDATE_API_URL, timeout=5) response.raise_for_status() release_data = response.json() latest_version = release_data.get("tag_name") # Проверяем, нужна ли загрузка if not latest_version or latest_version == APP_VERSION: return # Определяем имя нужного ассета в зависимости от ОС system = platform.system().lower() asset_name = None if system == "windows": asset_name = "factorio-mod-sync-windows.exe" elif system == "linux": asset_name = "factorio-mod-sync-linux" if not asset_name: return download_url = next((asset.get("browser_download_url") for asset in release_data.get("assets", []) if asset.get("name") == asset_name), None) if not download_url: return # Показываем небольшое окно с прогрессом update_win = tk.Toplevel(root) update_win.title("Обновление") update_win.geometry("350x100") tk.Label(update_win, text=f"Обнаружена новая версия ({latest_version}).\nЗагрузка обновления...\nПожалуйста, подождите.").pack(expand=True) update_win.update() new_exe_path = exe_path + ".new" with requests.get(download_url, stream=True, timeout=60) as r: r.raise_for_status() with open(new_exe_path, 'wb') as f: for chunk in r.iter_content(chunk_size=8192): f.write(chunk) if system == "linux": os.chmod(new_exe_path, 0o755) # Подменяем исполняемый файл (Windows позволяет переименовать запущенный файл) os.replace(exe_path, old_exe_path) os.replace(new_exe_path, exe_path) # Перезапускаем новую версию и выходим из текущей subprocess.Popen([exe_path] + sys.argv[1:]) sys.exit(0) except Exception as e: print(f"Ошибка проверки или установки обновления: {e}") # При ошибке просто продолжаем обычный запуск class SyncApp: def __init__(self, root): self.root = root self.root.title(f"Factorio Mod Sync {APP_VERSION}") self.root.geometry("500x350") self.config = self.load_config() self.setup_ui() def load_config(self): if CONFIG_FILE.exists(): data = json.loads(CONFIG_FILE.read_text()) return { "mods_path": data.get("mods_path", ""), "game_path": data.get("game_path", "") } return {"mods_path": "", "game_path": ""} def save_config(self, mods_path=None, game_path=None): config = self.load_config() if mods_path is not None: config["mods_path"] = mods_path if game_path is not None: config["game_path"] = game_path CONFIG_FILE.write_text(json.dumps(config)) def setup_ui(self): # Выбор пути tk.Label(self.root, text="Путь к папке mods:").pack(pady=(10, 0)) self.path_var = tk.StringVar(value=self.config["mods_path"]) entry_frame = tk.Frame(self.root) entry_frame.pack(fill="x", padx=20, pady=2) tk.Entry(entry_frame, textvariable=self.path_var).pack(side="left", fill="x", expand=True) tk.Button(entry_frame, text="Обзор", command=self.select_path).pack(side="right", padx=5) # Выбор пути к игре tk.Label(self.root, text="Путь к игре (exe):").pack(pady=(5, 0)) self.game_path_var = tk.StringVar(value=self.config["game_path"]) game_entry_frame = tk.Frame(self.root) game_entry_frame.pack(fill="x", padx=20, pady=2) tk.Entry(game_entry_frame, textvariable=self.game_path_var).pack(side="left", fill="x", expand=True) tk.Button(game_entry_frame, text="Обзор", command=self.select_game_path).pack(side="right", padx=5) # Статус и Прогресс self.status_label = tk.Label(self.root, text="Готов к работе", fg="blue") self.status_label.pack(pady=(10, 0)) self.progress = ttk.Progressbar(self.root, orient="horizontal", length=400, mode="determinate") self.progress.pack(pady=10) # Кнопки btn_frame = tk.Frame(self.root) btn_frame.pack(pady=10) self.sync_btn = tk.Button(btn_frame, text="СИНХРОНИЗИРОВАТЬ", command=self.start_sync_thread, bg="#2c3e50", fg="white", font=("Arial", 10, "bold"), height=2, width=18) self.sync_btn.pack(side="left", padx=10) self.launch_btn = tk.Button(btn_frame, text="ЗАПУСТИТЬ ИГРУ", command=self.launch_game, bg="#27ae60", fg="white", font=("Arial", 10, "bold"), height=2, width=18) self.launch_btn.pack(side="right", padx=10) def select_path(self): path = filedialog.askdirectory(title="Выберите папку mods") if path: self.path_var.set(path) self.save_config(mods_path=path) def select_game_path(self): path = filedialog.askopenfilename(title="Выберите исполняемый файл игры") if path: self.game_path_var.set(path) self.save_config(game_path=path) def launch_game(self): game_path = self.game_path_var.get() if not game_path or not Path(game_path).exists(): messagebox.showwarning("Внимание", "Сначала выберите правильный путь к исполняемому файлу игры!") return try: subprocess.Popen([game_path], cwd=os.path.dirname(game_path)) self.update_status("Игра запущена!", 100) except Exception as e: messagebox.showerror("Ошибка", f"Не удалось запустить игру:\n{e}") def update_status(self, text, value=None): self.status_label.config(text=text) if value is not None: self.progress["value"] = value self.root.update_idletasks() def start_sync_thread(self): # Запускаем в отдельном потоке, чтобы GUI не фризился thread = threading.Thread(target=self.run_sync) thread.start() def run_sync(self): mods_path = self.path_var.get() if not mods_path: messagebox.showwarning("Внимание", "Сначала выберите папку с модами!") return self.sync_btn.config(state="disabled") self.launch_btn.config(state="disabled") try: # 1. Получение манифеста self.update_status("Получение списка модов с сервера...", 5) response = requests.get(f"{SERVER_URL}mods_list.json", timeout=15) response.raise_for_status() server_manifest = response.json() server_base_names = {get_mod_base_name(f) for f in server_manifest.keys()} local_mods = Path(mods_path) if not local_mods.exists(): local_mods.mkdir(parents=True, exist_ok=True) mod_status = {} # --- ЭТАП ОЧИСТКИ (Удаление старых версий) --- self.update_status("Очистка старых версий модов...", 10) local_files = list(local_mods.glob("*.zip")) for local_file in local_files: if local_file.name not in server_manifest: local_base = get_mod_base_name(local_file.name) # Удаляем только если это старая версия мода из сборки (базовое имя совпадает) if local_base in server_base_names: print(f"Удаление старой версии: {local_file.name}") local_file.unlink() mod_status[local_file.name] = "удалено" # --------------------------------------- # 2. Синхронизация (Загрузка нужного) total_mods = len(server_manifest) current_mod_idx = 0 for filename, server_hash in server_manifest.items(): current_mod_idx += 1 self.update_status(f"Синхронизация: {current_mod_idx}/{total_mods}", 20 + (current_mod_idx / total_mods) * 80) local_file = local_mods / filename file_exists = local_file.exists() if not file_exists: needs_download = True mod_status[filename] = "добавлено" elif get_file_hash(local_file) != server_hash: needs_download = True mod_status[filename] = "обновлено" else: needs_download = False mod_status[filename] = "без изменений" if needs_download: self.update_status(f"Загрузка: {filename}...", self.progress["value"]) with requests.get(f"{SERVER_URL}{filename}", stream=True) as r: r.raise_for_status() with open(local_file, "wb") as f: for chunk in r.iter_content(chunk_size=8192): f.write(chunk) self.update_status("Синхронизация завершена!", 100) # --- СОРТИРОВКА И ВЫВОД ОТЧЕТА --- # Сортируем просто по алфавиту для удобного git-diff стиля sorted_mods = sorted(mod_status.items(), key=lambda item: item[0]) # Безопасный вызов отрисовки UI из фонового потока self.root.after(0, self.show_report, sorted_mods) except Exception as e: self.update_status("Ошибка!", 0) self.root.after(0, lambda: messagebox.showerror("Ошибка", str(e))) finally: self.root.after(0, lambda: self.sync_btn.config(state="normal")) self.root.after(0, lambda: self.launch_btn.config(state="normal")) def show_report(self, sorted_mods): report_win = tk.Toplevel(self.root) report_win.title("Отчет о синхронизации") report_win.geometry("400x300") # Компактный размер # Текстовое поле с темным фоном для читаемости цветного текста text_widget = tk.Text(report_win, font=("Consolas", 10), bg="#1e1e1e", fg="white", wrap="none") scrollbar_y = ttk.Scrollbar(report_win, orient="vertical", command=text_widget.yview) text_widget.configure(yscrollcommand=scrollbar_y.set) scrollbar_y.pack(side="right", fill="y") text_widget.pack(side="left", fill="both", expand=True, padx=5, pady=5) # Настраиваем цветовые теги text_widget.tag_config("добавлено", foreground="#55ff55") # Светло-зеленый text_widget.tag_config("удалено", foreground="#ff5555") # Светло-красный text_widget.tag_config("обновлено", foreground="#ffff55") # Желтый text_widget.tag_config("без изменений", foreground="#888888") # Серый # Заполняем поле for mod, status in sorted_mods: if status == "добавлено": text_widget.insert(tk.END, f"+ {mod}\n", status) elif status == "удалено": text_widget.insert(tk.END, f"- {mod}\n", status) elif status == "обновлено": text_widget.insert(tk.END, f"~ {mod}\n", status) elif status == "без изменений": text_widget.insert(tk.END, f" {mod}\n", status) text_widget.config(state="disabled") # Только чтение if __name__ == "__main__": root = tk.Tk() root.withdraw() # Скрываем основное окно на время проверки обновления check_and_update(root) root.deiconify() # Показываем основное окно app = SyncApp(root) root.mainloop()