import os 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 # --- КОНФИГУРАЦИЯ --- 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() class SyncApp: def __init__(self, root): self.root = root self.root.title("Factorio Mod Sync") 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() app = SyncApp(root) root.mainloop()