client-py/main.py
borderban 9155d1e50a
All checks were successful
Build and Publish Release / Build Linux (release) Successful in 2m5s
Build and Publish Release / Build Windows (release) Successful in 6m56s
Build and Publish Release / Upload Assets to Release (release) Successful in 15s
автообновление
2026-05-03 22:45:06 +05:00

322 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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()