Skip to content

Instantly share code, notes, and snippets.

@Hammer2900
Last active June 8, 2026 05:36
Show Gist options
  • Select an option

  • Save Hammer2900/a19fcb01268011f71a1c90fd72edf30b to your computer and use it in GitHub Desktop.

Select an option

Save Hammer2900/a19fcb01268011f71a1c90fd72edf30b to your computer and use it in GitHub Desktop.
anomaly mod manager utility
import sys
import asyncio
import inspect
import os
import json
import shutil
import tkinter as tk
from tkinter import ttk, filedialog
from tkinter import font as tk_font
from typing import Any, Callable, Optional, Dict, List
class EventBus:
def __init__(self):
self._listeners: Dict[str, List[Callable]] = {}
def on(self, event: str):
def wrapper(func):
if not hasattr(func, '_bus_events'):
func._bus_events = []
func._bus_events.append(event)
return func
return wrapper
def subscribe(self, event: str, callback: Callable):
if event not in self._listeners:
self._listeners[event] = []
self._listeners[event].append(callback)
def register(self, obj: Any):
for name, method in inspect.getmembers(obj, predicate=inspect.ismethod):
if hasattr(method, '_bus_events'):
for event in method._bus_events:
self.subscribe(event, method)
def emit(self, event: str, *args, **kwargs):
for callback in self._listeners.get(event, []):
callback(*args, **kwargs)
bus = EventBus()
def detect_mods_in_archive(file_list: List[str]) -> Dict[str, str]:
mods = {}
for path in file_list:
parts = path.strip('/').split('/')
if 'gamedata' in parts:
idx = parts.index('gamedata')
mod_root_parts = parts[:idx]
mod_root_path = '/'.join(mod_root_parts)
if mod_root_path:
mod_root_path += '/'
mod_name = '/'.join(mod_root_parts) if mod_root_parts else '[Root]'
if mod_name not in mods:
mods[mod_name] = mod_root_path
return mods
_CTX: list[Any] = []
def _top() -> Any:
if not _CTX:
raise RuntimeError('Нет активного UI-контекста.')
return _CTX[-1]
def _push(obj: Any) -> Any:
_CTX.append(obj)
return obj
def _pop() -> Any:
return _CTX.pop()
def _tk_parent() -> tk.Widget:
for item in reversed(_CTX):
if isinstance(item, tk.Widget):
return item
if hasattr(item, 'frame'):
return item.frame
raise RuntimeError('Не найден tk-виджет в стеке контекста.')
def _has_place(obj: Any) -> bool:
return hasattr(obj, '_place') and callable(obj._place)
class App:
def __init__(self, title: str = 'App', geometry: str = '1200x800'):
self.root = tk.Tk()
self.root.title(title)
self.root.geometry(geometry)
style = ttk.Style(self.root)
style.theme_use('clam')
style.configure('.', background='#2b2b2b', foreground='white', fieldbackground='#1e1e1e')
style.configure('TButton', background='#3c3f41', borderwidth=1)
style.map('TButton', background=[('active', '#4c5052')])
style.configure('TLabel', background='#2b2b2b')
style.configure('TLabelframe', background='#2b2b2b', borderwidth=1)
style.configure('TLabelframe.Label', background='#2b2b2b', foreground='#a9b7c6')
style.configure('Treeview', background='#1e1e1e', fieldbackground='#1e1e1e', foreground='white', rowheight=24)
style.map('Treeview', background=[('selected', '#2f65ca')])
self.root.configure(bg='#2b2b2b')
self._main: Optional[ttk.Frame] = None
def __enter__(self) -> 'App':
self._main = ttk.Frame(self.root, padding='10')
self._main.pack(fill='both', expand=True)
_push(self._main)
return self
def __exit__(self, *_) -> None:
_pop()
def run(self) -> None:
self.root.mainloop()
class Panel:
def __init__(self, title: str = '', expand: bool = False, padding: str = '10'):
parent = _tk_parent()
self.frame = ttk.LabelFrame(parent, text=title, padding=padding)
self.frame.pack(fill='both' if expand else 'x', expand=expand, pady=5, padx=5)
def __enter__(self) -> 'Panel':
_push(self.frame)
return self
def __exit__(self, *_) -> None:
_pop()
class HPaned:
def __init__(self, fill: str = 'both', expand: bool = True, pady: int = 5):
parent = _tk_parent()
self._paned = ttk.PanedWindow(parent, orient=tk.HORIZONTAL)
self._paned.pack(fill=fill, expand=expand, pady=pady)
def __enter__(self) -> 'HPaned':
return self
def __exit__(self, *_) -> None:
pass
def pane(self, title: str = '', weight: int = 1) -> '_Pane':
return _Pane(self._paned, title, weight)
class _Pane:
def __init__(self, paned: ttk.PanedWindow, title: str, weight: int):
self.frame = ttk.Frame(paned)
paned.add(self.frame, weight=weight)
def __enter__(self) -> '_Pane':
_push(self.frame)
return self
def __exit__(self, *_) -> None:
_pop()
class Form:
def __init__(self):
parent = _tk_parent()
self.frame = ttk.Frame(parent)
self.frame.pack(fill='x')
self.frame.columnconfigure(1, weight=1)
self._row_idx = 0
def __enter__(self) -> 'Form':
_push(self)
return self
def __exit__(self, *_) -> None:
_pop()
def row(self, label_text: str = '') -> '_FormRow':
r = _FormRow(self, label_text)
self._row_idx += 1
return r
class _FormRow:
def __init__(self, form: Form, label_text: str):
self._form = form
self._row = form._row_idx
self._col = 1
self.widgets = []
if label_text:
self.label = ttk.Label(form.frame, text=label_text)
self.label.grid(row=self._row, column=0, sticky='w', padx=5, pady=5)
def __enter__(self) -> '_FormRow':
_push(self)
return self
def __exit__(self, *_) -> None:
_pop()
def _place(self, factory: Callable, colspan: int = 1) -> tk.Widget:
w = factory(self._form.frame, self._row, self._col)
self._col += colspan
self.widgets.append(w)
return w
class Row:
def __init__(self, pady: int = 2):
parent = _tk_parent()
self.frame = ttk.Frame(parent)
self.frame.pack(fill='x', pady=pady)
def __enter__(self) -> 'Row':
_push(self.frame)
return self
def __exit__(self, *_) -> None:
_pop()
def Button(text: str, command: Optional[Callable] = None, **kw) -> ttk.Button:
c = _top()
if _has_place(c):
def factory(parent, r, col):
btn = ttk.Button(parent, text=text, command=command, **kw)
btn.grid(row=r, column=col, sticky='ew', padx=5, pady=5)
return btn
return c._place(factory)
else:
btn = ttk.Button(_tk_parent(), text=text, command=command, **kw)
btn.pack(fill='x', pady=4)
return btn
def _best_mono_font(size: int) -> tuple:
available = tk_font.families()
candidates = ['Consolas', 'JetBrains Mono', 'Fira Code', 'Courier New', 'TkFixedFont']
for name in candidates:
if name in available:
return (name, size)
return ('TkFixedFont', size)
class Logger:
def __init__(self):
parent = _tk_parent()
frame = ttk.Frame(parent)
frame.pack(fill='both', expand=True)
font = _best_mono_font(11)
self._w = tk.Text(
frame,
wrap=tk.WORD,
state='disabled',
bg='#1e1e1e',
fg='#d4d4d4',
font=font,
relief='flat',
borderwidth=1,
padx=8,
pady=6,
)
self._w.pack(side=tk.LEFT, fill='both', expand=True)
self._w.tag_config('error', foreground='#f44747')
self._w.tag_config('stdout', foreground='#b5cea8')
sb = ttk.Scrollbar(frame, command=self._w.yview)
sb.pack(side=tk.RIGHT, fill='y')
self._w.config(yscrollcommand=sb.set)
def write(self, message: str, is_error: bool = False) -> None:
tag = 'error' if is_error else 'stdout'
self._w.config(state='normal')
self._w.insert(tk.END, message, tag)
self._w.see(tk.END)
self._w.config(state='disabled')
self._w.update_idletasks()
def clear(self) -> None:
self._w.config(state='normal')
self._w.delete(1.0, tk.END)
self._w.config(state='disabled')
def TkComboBox(pub_select: str = '', sub_items: str = '') -> ttk.Combobox:
var = tk.StringVar()
c = _top()
def factory(parent, r, col):
cb = ttk.Combobox(parent, textvariable=var, state='readonly')
cb.grid(row=r, column=col, sticky='ew', padx=5, pady=5)
return cb
if _has_place(c):
cb = c._place(factory)
else:
cb = ttk.Combobox(_tk_parent(), textvariable=var, state='readonly')
cb.pack(fill='x', padx=5, pady=2)
if pub_select:
cb.bind('<<ComboboxSelected>>', lambda e: bus.emit(pub_select, var.get()))
if sub_items:
def update_items(items: list):
cb['values'] = items
if items:
var.set(items[0])
bus.emit(pub_select, items[0])
else:
var.set('')
bus.subscribe(sub_items, update_items)
return cb
def TkListbox(pub_select: str = '', sub_items: str = '', sub_clear: str = '') -> tk.Listbox:
parent = _tk_parent()
frame = ttk.Frame(parent)
frame.pack(fill='both', expand=True)
lb = tk.Listbox(
frame,
font=tk_font.Font(family='Segoe UI', size=11),
bg='#1e1e1e',
fg='white',
selectbackground='#2f65ca',
activestyle='none',
relief='flat',
borderwidth=1,
)
lb.pack(side=tk.LEFT, fill='both', expand=True)
sb = ttk.Scrollbar(frame, orient='vertical', command=lb.yview)
sb.pack(side=tk.RIGHT, fill='y')
lb.config(yscrollcommand=sb.set)
if pub_select:
def on_select(evt):
sel = lb.curselection()
if sel:
bus.emit(pub_select, lb.get(sel[0]))
lb.bind('<<ListboxSelect>>', on_select)
if sub_items:
def update_items(items: list):
lb.delete(0, tk.END)
for item in items:
lb.insert(tk.END, item)
bus.subscribe(sub_items, update_items)
if sub_clear:
bus.subscribe(sub_clear, lambda: lb.delete(0, tk.END))
return lb
class ArchiveTreeWidget:
def __init__(self):
parent = _tk_parent()
frame = ttk.Frame(parent)
frame.pack(fill='both', expand=True)
self.tree = ttk.Treeview(frame, show='tree', selectmode='browse')
self.tree.pack(side=tk.LEFT, fill='both', expand=True)
sb = ttk.Scrollbar(frame, orient='vertical', command=self.tree.yview)
sb.pack(side=tk.RIGHT, fill='y')
self.tree.config(yscrollcommand=sb.set)
self.tree.tag_configure('installed', foreground='#f44747')
self.tree.tag_configure('virtual', foreground='#a0a0a0', font=tk_font.Font(slant='italic'))
self.tree.tag_configure('normal', foreground='white')
self.item_data = {}
self.root_path = ''
self.installed_set = set()
self.sources = []
self.tree.bind('<<TreeviewSelect>>', self._on_select)
self.tree.bind('<Double-1>', self._on_double_click)
self.tree.bind('<Button-3>', self._show_context_menu)
self.tree.bind('<Button-2>', self._show_context_menu)
bus.subscribe('update_installed_list_raw', self.set_installed_mods_raw)
bus.subscribe('set_source_combo', lambda s: setattr(self, 'sources', s))
def set_installed_mods_raw(self, installed_list: list):
self.installed_set = {os.path.normpath(x['archive']) for x in installed_list}
self._update_colors()
def _update_colors(self):
for iid, data in self.item_data.items():
if not self.tree.exists(iid):
continue
if data['is_virtual']:
self.tree.item(iid, tags=('virtual',))
else:
norm_p = os.path.normpath(data['path'])
if norm_p in self.installed_set:
self.tree.item(iid, tags=('installed',))
else:
self.tree.item(iid, tags=('normal',))
def set_root_path(self, path: str):
self.root_path = path
self.tree.delete(*self.tree.get_children())
self.item_data.clear()
if not path or not os.path.exists(path):
return
self.populate_directory(path, '')
self._update_colors()
def populate_directory(self, dir_path: str, parent_iid: str):
try:
entries = sorted(
os.listdir(dir_path), key=lambda x: (not os.path.isdir(os.path.join(dir_path, x)), x.lower())
)
except Exception:
return
for entry in entries:
full_path = os.path.join(dir_path, entry)
if entry.endswith('.meta') or entry.startswith('.'):
continue
is_dir = os.path.isdir(full_path)
is_archive = any(entry.lower().endswith(ext) for ext in ['.zip', '.rar', '.7z'])
if is_dir or is_archive:
icon = '📁 ' if is_dir else '📦 '
iid = self.tree.insert(parent_iid, 'end', text=icon + entry, open=False)
self.item_data[iid] = {
'path': full_path,
'is_dir': is_dir,
'is_archive': is_archive,
'is_virtual': False,
}
if is_dir:
self.populate_directory(full_path, iid)
elif is_archive:
self.populate_virtual_meta(full_path, iid)
def populate_virtual_meta(self, archive_path: str, parent_iid: str):
meta_path = archive_path + '.meta'
if os.path.exists(meta_path):
try:
with open(meta_path, 'r', encoding='utf-8') as f:
data = json.load(f)
self.build_virtual_tree(data.get('file_list', []), parent_iid)
except Exception:
pass
def build_virtual_tree(self, file_list: list, archive_iid: str):
nodes = {'': archive_iid}
for path in sorted(file_list):
parts = path.strip('/').split('/')
current_path = ''
for part in parts:
if not part:
continue
parent_path = current_path
current_path = os.path.join(current_path, part)
if current_path not in nodes:
parent_iid = nodes[parent_path]
iid = self.tree.insert(parent_iid, 'end', text='📄 ' + part)
self.item_data[iid] = {'path': '', 'is_dir': False, 'is_archive': False, 'is_virtual': True}
nodes[current_path] = iid
def _resolve_real_archive(self, iid: str) -> Optional[str]:
current = iid
while current:
data = self.item_data.get(current)
if data and not data['is_virtual']:
return current
current = self.tree.parent(current)
return None
def _on_select(self, event):
sel = self.tree.selection()
if not sel:
return
actual_iid = self._resolve_real_archive(sel[0])
if actual_iid:
data = self.item_data[actual_iid]
if data['is_archive']:
bus.emit('archive_selected', data['path'])
return
bus.emit('archive_selected', None)
def _on_double_click(self, event):
sel = self.tree.selection()
if not sel:
return
actual_iid = self._resolve_real_archive(sel[0])
if actual_iid:
data = self.item_data[actual_iid]
if data['is_archive']:
bus.emit('archive_selected', data['path'])
bus.emit('unpack_clicked')
def _show_context_menu(self, event):
iid = self.tree.identify_row(event.y)
if not iid:
return
self.tree.selection_set(iid)
actual_iid = self._resolve_real_archive(iid)
if not actual_iid:
return
data = self.item_data[actual_iid]
src_path = data['path']
menu = tk.Menu(self.tree, tearoff=0, bg='#2b2b2b', fg='white')
if data['is_archive']:
menu.add_command(
label='🔍 Прочитать структуру архива', command=lambda: bus.emit('read_unpack_archive', src_path)
)
menu.add_command(label='📥 Распаковать архив в цель', command=lambda: bus.emit('unpack_clicked'))
menu.add_separator()
destinations = [d for d in self.sources if os.path.normpath(d) != os.path.normpath(self.root_path)]
for dest in destinations:
dest_name = os.path.basename(dest) or dest
sub_menu = tk.Menu(menu, tearoff=0, bg='#2b2b2b', fg='white')
sub_menu.add_command(label='Скопировать', command=lambda s=src_path, d=dest: bus.emit('copy_item', (s, d)))
sub_menu.add_command(label='Переместить', command=lambda s=src_path, d=dest: bus.emit('move_item', (s, d)))
menu.add_cascade(label=f'В папку: {dest_name}', menu=sub_menu)
menu.post(event.x_root, event.y_root)
class FlatModTreeWidget:
def __init__(self):
parent = _tk_parent()
frame = ttk.Frame(parent)
frame.pack(fill='both', expand=True)
self.tree = ttk.Treeview(frame, show='tree', selectmode='browse')
self.tree.pack(side=tk.LEFT, fill='both', expand=True)
sb = ttk.Scrollbar(frame, orient='vertical', command=self.tree.yview)
sb.pack(side=tk.RIGHT, fill='y')
self.tree.config(yscrollcommand=sb.set)
self.tree.tag_configure('installed', foreground='#f44747')
self.tree.tag_configure('normal', foreground='white')
self.tree.tag_configure('gray', foreground='#a0a0a0')
self.item_data = {}
self.installed_set = set()
self.installed_archives = set()
self.tree.bind('<<TreeviewSelect>>', self._on_select)
self.tree.bind('<Double-1>', self._on_double_click)
bus.subscribe('update_installed_list_raw', self.set_installed_mods_raw)
def set_installed_mods_raw(self, installed_list: list):
self.installed_set = {(os.path.normpath(x['archive']), x['option']) for x in installed_list}
self.installed_archives = {os.path.normpath(x['archive']) for x in installed_list}
self._update_colors()
def _update_colors(self):
for iid, data in self.item_data.items():
if not self.tree.exists(iid):
continue
if data['is_header']:
if data['archive_path'] and os.path.normpath(data['archive_path']) in self.installed_archives:
self.tree.item(iid, tags=('installed',))
else:
self.tree.item(iid, tags=('normal',))
elif data['mod_name']:
key = (os.path.normpath(data['archive_path']), data['mod_name'])
if key in self.installed_set:
self.tree.item(iid, tags=('installed',))
else:
self.tree.item(iid, tags=('normal',))
def set_root_path(self, path: str):
self.tree.delete(*self.tree.get_children())
self.item_data.clear()
if not path or not os.path.exists(path):
return
self.populate_flat_mods(path)
def populate_flat_mods(self, dir_path: str):
supported_exts = ('.zip', '.rar', '.7z')
for root_dir, _, files in os.walk(dir_path):
for file in files:
if file.lower().endswith(supported_exts):
archive_path = os.path.join(root_dir, file)
meta_path = archive_path + '.meta'
iid = self.tree.insert('', 'end', text='📦 ' + file, open=True)
self.item_data[iid] = {
'archive_path': archive_path,
'is_header': True,
'rel_path': '',
'mod_name': '',
}
if os.path.exists(meta_path):
try:
with open(meta_path, 'r', encoding='utf-8') as f:
data = json.load(f)
mods = detect_mods_in_archive(data.get('file_list', []))
if not mods:
child = self.tree.insert(iid, 'end', text='[Модов нет]', tags=('gray',))
self.item_data[child] = {
'archive_path': '',
'is_header': False,
'rel_path': '',
'mod_name': '',
}
else:
for mod_name, rel_path in mods.items():
child = self.tree.insert(iid, 'end', text='⚙ ' + mod_name)
self.item_data[child] = {
'archive_path': archive_path,
'is_header': False,
'rel_path': rel_path,
'mod_name': mod_name,
}
except Exception:
pass
else:
child = self.tree.insert(iid, 'end', text='[Не просканирован]', tags=('gray',))
self.item_data[child] = {'archive_path': '', 'is_header': False, 'rel_path': '', 'mod_name': ''}
def _on_select(self, event):
sel = self.tree.selection()
if not sel:
return
data = self.item_data[sel[0]]
if data['is_header']:
bus.emit('archive_selected', data['archive_path'])
bus.emit('mod_option_selected', None)
elif data['mod_name']:
bus.emit('archive_selected', data['archive_path'])
bus.emit('mod_option_selected', (data['archive_path'], data['rel_path'], data['mod_name']))
else:
bus.emit('mod_option_selected', None)
def _on_double_click(self, event):
sel = self.tree.selection()
if not sel:
return
data = self.item_data[sel[0]]
if not data['is_header'] and data['mod_name']:
self._on_select(event)
bus.emit('unpack_clicked')
class BaseUnpacker:
def __init__(self, archive_path: str):
self.archive_path = archive_path
def get_file_list(self) -> list:
raise NotImplementedError
def extract(self, dest_dir: str):
raise NotImplementedError
class ZipUnpacker(BaseUnpacker):
def get_file_list(self) -> list:
import zipfile
try:
with zipfile.ZipFile(self.archive_path, 'r') as z:
return z.namelist()
except Exception as e:
raise IOError(f'Ошибка чтения ZIP: {e}')
def extract(self, dest_dir: str):
import zipfile
os.makedirs(dest_dir, exist_ok=True)
with zipfile.ZipFile(self.archive_path, 'r') as z:
z.extractall(dest_dir)
class RarUnpacker(BaseUnpacker):
def _check_library(self):
try:
import rarfile
return rarfile
except ImportError:
raise ImportError("Библиотека 'rarfile' не установлена.")
def get_file_list(self) -> list:
rf = self._check_library()
try:
with rf.RarFile(self.archive_path) as r:
return r.namelist()
except Exception as e:
raise IOError(f'Ошибка чтения RAR: {e}')
def extract(self, dest_dir: str):
rf = self._check_library()
os.makedirs(dest_dir, exist_ok=True)
try:
with rf.RarFile(self.archive_path) as r:
r.extractall(dest_dir)
except Exception as e:
raise IOError(f'Ошибка извлечения RAR: {e}')
class SevenZipUnpacker(BaseUnpacker):
def _check_library(self):
try:
import py7zr
return py7zr
except ImportError:
raise ImportError("Библиотека 'py7zr' не установлена.")
def get_file_list(self) -> list:
sz = self._check_library()
try:
with sz.SevenZipFile(self.archive_path, mode='r') as s:
return s.getnames()
except Exception as e:
raise IOError(f'Ошибка чтения 7Z: {e}')
def extract(self, dest_dir: str):
sz = self._check_library()
os.makedirs(dest_dir, exist_ok=True)
try:
with sz.SevenZipFile(self.archive_path, mode='r') as s:
s.extractall(dest_dir)
except Exception as e:
raise IOError(f'Ошибка извлечения 7Z: {e}')
class ArchiveProcessor:
@staticmethod
def get_unpacker(archive_path: str) -> BaseUnpacker:
ext = archive_path.lower().split('.')[-1]
if ext == 'zip':
return ZipUnpacker(archive_path)
elif ext == 'rar':
return RarUnpacker(archive_path)
elif ext == '7z':
return SevenZipUnpacker(archive_path)
else:
raise ValueError(f'Формат .{ext} не поддерживается')
@classmethod
def process_archive(cls, archive_path: str) -> dict:
meta_path = archive_path + '.meta'
if os.path.exists(meta_path):
try:
with open(meta_path, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception:
pass
unpacker = cls.get_unpacker(archive_path)
file_list = unpacker.get_file_list()
meta_data = {'archive_name': os.path.basename(archive_path), 'file_list': file_list}
try:
with open(meta_path, 'w', encoding='utf-8') as f:
json.dump(meta_data, f, indent=4, ensure_ascii=False)
except Exception as e:
raise IOError(f'Не удалось записать метаданные: {e}')
return meta_data
@classmethod
def extract_mod_option(cls, archive_path: str, rel_path: str, dest_dir: str) -> List[str]:
unpacker = cls.get_unpacker(archive_path)
temp_extract_dir = os.path.join(dest_dir, '_temp_extract')
if os.path.exists(temp_extract_dir):
shutil.rmtree(temp_extract_dir)
os.makedirs(temp_extract_dir, exist_ok=True)
extracted_files = []
try:
unpacker.extract(temp_extract_dir)
source_option_dir = os.path.normpath(os.path.join(temp_extract_dir, rel_path))
if os.path.exists(source_option_dir):
for root, _, files in os.walk(source_option_dir):
for file in files:
full_file_path = os.path.join(root, file)
rel_to_option = os.path.relpath(full_file_path, source_option_dir)
target_file_path = os.path.join(dest_dir, rel_to_option)
os.makedirs(os.path.dirname(target_file_path), exist_ok=True)
if os.path.exists(target_file_path):
os.remove(target_file_path)
shutil.move(full_file_path, target_file_path)
extracted_files.append(rel_to_option.replace('\\', '/'))
finally:
if os.path.exists(temp_extract_dir):
shutil.rmtree(temp_extract_dir)
return extracted_files
class ModManagerController:
def __init__(self, config_path: str):
self.config_path = config_path
self.source_dirs = []
self.current_source = ''
self.target_dirs = []
self.current_target = ''
self.selected_archive = None
self.selected_mod_option = None
self.selected_installed_mod = None
self.installed_mods = {}
bus.register(self)
def _check_archivers_availability(self):
bus.emit('log_write', '=== ПРОВЕРКА ПОДДЕРЖКИ АРХИВАТОРОВ ===\n')
bus.emit('log_write', ' [OK] ZIP: Поддерживается по умолчанию.\n')
try:
import rarfile
bus.emit('log_write', " [OK] RAR: Модуль 'rarfile' обнаружен.\n")
except ImportError:
bus.emit('log_write', " [!] RAR: Модуль 'rarfile' не установлен.\n", True)
try:
import py7zr
bus.emit('log_write', " [OK] 7Z: Модуль 'py7zr' обнаружен.\n")
except ImportError:
bus.emit('log_write', " [!] 7Z: Модуль 'py7zr' не установлен.\n", True)
bus.emit('log_write', '======================================\n\n')
def save_config(self):
try:
with open(self.config_path, 'w', encoding='utf-8') as f:
json.dump(
{
'source_archives': self.source_dirs,
'targets': self.target_dirs,
'installed_mods': self.installed_mods,
},
f,
indent=4,
ensure_ascii=False,
)
except Exception as e:
bus.emit('log_write', f'Ошибка сохранения конфигурации: {e}\n', True)
@bus.on('app_started')
def load_config(self):
bus.emit('log_write', 'Загрузка конфигурации...\n')
try:
if os.path.exists(self.config_path):
with open(self.config_path, 'r', encoding='utf-8') as f:
data = json.load(f)
self.source_dirs = data.get('source_archives', [])
self.target_dirs = data.get('targets', [])
self.installed_mods = data.get('installed_mods', {})
for target in self.target_dirs:
if target not in self.installed_mods:
self.installed_mods[target] = []
bus.emit('set_source_combo', self.source_dirs)
bus.emit('set_target_combo', self.target_dirs)
bus.emit(
'log_write',
f'Конфиг успешно загружен. Источников: {len(self.source_dirs)}, Целей: {len(self.target_dirs)}.\n\n',
)
self._check_archivers_availability()
except Exception as e:
bus.emit('log_write', f'Ошибка загрузки конфига: {e}\n', True)
@bus.on('read_unpack_archive')
def on_read_unpack_archive(self, archive_path: str):
name = os.path.basename(archive_path)
bus.emit('log_write', f'Анализ структуры архива: {name}...\n')
try:
ArchiveProcessor.process_archive(archive_path)
bus.emit('log_write', f'Метаданные успешно получены для {name}.\n')
bus.emit('set_tree_root', self.current_source)
except Exception as e:
bus.emit('log_write', f'Ошибка чтения архива {name}: {e}\n', True)
@bus.on('scan_folder_clicked')
def on_scan_current_source_folder(self):
if not self.current_source:
bus.emit('log_write', 'ОШИБКА: Не выбрана папка с архивами для сканирования!\n', True)
return
bus.emit('log_write', f'=== Начат анализ папки: {self.current_source} ===\n')
scanned_count, error_count = 0, 0
try:
for root, _, files in os.walk(self.current_source):
for file in files:
if file.lower().endswith(('.zip', '.rar', '.7z')):
try:
ArchiveProcessor.process_archive(os.path.join(root, file))
scanned_count += 1
except Exception as e:
bus.emit('log_write', f'[ОШИБКА ИГНОРИРОВАНА] Пропуск {file}: {e}\n', True)
error_count += 1
bus.emit('log_write', f'=== Анализ завершен. Успешно: {scanned_count}. Ошибок: {error_count}. ===\n')
bus.emit('set_tree_root', self.current_source)
except Exception as e:
bus.emit('log_write', f'Ошибка доступа к директории: {e}\n', True)
@bus.on('browse_add_target')
def browse_add_target(self):
path = filedialog.askdirectory(title='Выберите целевую папку (куда ставить)')
if path and path not in self.target_dirs:
self.target_dirs.append(path)
if path not in self.installed_mods:
self.installed_mods[path] = []
bus.emit('set_target_combo', self.target_dirs)
self.save_config()
bus.emit('log_write', f'Добавлена целевая папка: {path}\n')
@bus.on('browse_add_source')
def browse_add_source(self):
path = filedialog.askdirectory(title='Выберите папку с архивами (откуда брать)')
if path and path not in self.source_dirs:
self.source_dirs.append(path)
bus.emit('set_source_combo', self.source_dirs)
self.save_config()
bus.emit('log_write', f'Добавлена папка с архивами: {path}\n')
@bus.on('source_changed')
def on_source_changed(self, source: str):
self.current_source = source
if source:
bus.emit('set_tree_root', source)
bus.emit('log_write', f'Выбрана папка архивов: {source}\n')
@bus.on('target_changed')
def on_target_changed(self, target: str):
self.current_target = target
bus.emit('log_write', f'Выбрана целевая папка: {target}\n')
self._refresh_installed_list()
@bus.on('archive_selected')
def on_archive_selected(self, file_path: Optional[str]):
self.selected_archive = file_path
@bus.on('mod_option_selected')
def on_mod_option_selected(self, option_data: Optional[tuple]):
self.selected_mod_option = option_data
@bus.on('installed_mod_selected')
def on_installed_mod_selected(self, formatted_name: str):
self.selected_installed_mod = formatted_name
@bus.on('copy_item')
def on_copy_item(self, payload: tuple):
src_path, dest_dir = payload
dest_path = os.path.join(dest_dir, os.path.basename(src_path))
bus.emit('log_write', f'Копирование: {os.path.basename(src_path)} -> {os.path.basename(dest_dir)}...\n')
try:
if os.path.isdir(src_path):
shutil.copytree(src_path, dest_path, dirs_exist_ok=True)
else:
shutil.copy2(src_path, dest_path)
bus.emit('log_write', f'Успешно скопировано в: {dest_path}\n')
bus.emit('set_tree_root', self.current_source)
except Exception as e:
bus.emit('log_write', f'Ошибка копирования: {e}\n', True)
@bus.on('move_item')
def on_move_item(self, payload: tuple):
src_path, dest_dir = payload
dest_path = os.path.join(dest_dir, os.path.basename(src_path))
bus.emit('log_write', f'Перемещение: {os.path.basename(src_path)} -> {os.path.basename(dest_dir)}...\n')
try:
shutil.move(src_path, dest_path)
bus.emit('log_write', f'Успешно перемещено в: {dest_path}\n')
bus.emit('set_tree_root', self.current_source)
except Exception as e:
bus.emit('log_write', f'Ошибка перемещения: {e}\n', True)
@bus.on('unpack_clicked')
def unpack_mod(self):
if not self.current_target:
bus.emit('log_write', 'ОШИБКА: Целевая папка не выбрана!\n', True)
return
archive_path, rel_path, mod_name = None, '', ''
if self.selected_mod_option:
archive_path, rel_path, mod_name = self.selected_mod_option
elif self.selected_archive:
archive_path = self.selected_archive
meta_path = archive_path + '.meta'
if os.path.exists(meta_path):
try:
with open(meta_path, 'r', encoding='utf-8') as f:
mods = detect_mods_in_archive(json.load(f).get('file_list', []))
if len(mods) == 1:
mod_name, rel_path = list(mods.items())[0]
elif len(mods) > 1:
return bus.emit('log_write', 'ВНИМАНИЕ: Выберите нужную опцию в нижней панели!\n', True)
else:
return bus.emit('log_write', 'ОШИБКА: В архиве не найдена папка gamedata!\n', True)
except Exception as e:
return bus.emit('log_write', f'Ошибка парсинга метаданных: {e}\n', True)
else:
return bus.emit(
'log_write', 'ВНИМАНИЕ: Архив не просканирован. Нажмите ПКМ -> Прочитать структуру!\n', True
)
else:
return bus.emit('log_write', 'ОШИБКА: Архив или опция мода не выбрана!\n', True)
archive_file = os.path.basename(archive_path)
for item in self.installed_mods.get(self.current_target, []):
if item['archive'] == archive_path and item['option'] == mod_name:
return bus.emit(
'log_write', f"ВНИМАНИЕ: Опция '{mod_name}' из '{archive_file}' уже установлена!\n", True
)
bus.emit('log_write', f'>>> УСТАНОВКА ОПЦИИ: {mod_name} из {archive_file}...\n')
try:
created_files = ArchiveProcessor.extract_mod_option(archive_path, rel_path, self.current_target)
self.installed_mods[self.current_target].append(
{'archive': archive_path, 'option': mod_name, 'files': created_files}
)
self.save_config()
self._refresh_installed_list()
bus.emit('log_write', f'Опция "{mod_name}" успешно установлена. Распаковано файлов: {len(created_files)}\n')
except Exception as e:
bus.emit('log_write', f'Ошибка при распаковке архива: {e}\n', True)
@bus.on('remove_clicked')
def remove_mod(self):
if not self.selected_installed_mod or not self.current_target:
return
target_installs = self.installed_mods.get(self.current_target, [])
matched_item = next(
(
i
for i in target_installs
if f'{os.path.basename(i["archive"])} ➔ {i["option"]}' == self.selected_installed_mod
),
None,
)
if not matched_item:
return
bus.emit('log_write', f'<<< УДАЛЕНИЕ: {matched_item["option"]} из {self.current_target}...\n')
deleted_count = 0
for rel_file in matched_item.get('files', []):
full_path = os.path.join(self.current_target, rel_file)
if os.path.exists(full_path):
try:
os.remove(full_path)
deleted_count += 1
except Exception:
pass
for root, dirs, _ in os.walk(self.current_target, topdown=False):
for d in dirs:
try:
if not os.listdir(os.path.join(root, d)):
os.rmdir(os.path.join(root, d))
except Exception:
pass
self.installed_mods[self.current_target].remove(matched_item)
self.selected_installed_mod = None
self.save_config()
self._refresh_installed_list()
bus.emit('log_write', f'Мод успешно деинсталлирован. Удалено файлов: {deleted_count}\n')
def _refresh_installed_list(self):
mods = self.installed_mods.get(self.current_target, [])
bus.emit('update_installed_list', [f'{os.path.basename(i["archive"])} ➔ {i["option"]}' for i in mods])
bus.emit('update_installed_list_raw', mods)
def build_ui():
app = App('S.T.A.L.K.E.R. Anomaly Mod Manager (Tkinter)', '1300x850')
with app:
with Panel('Настройки директорий'):
with Form() as f:
with f.row('Папки с модами (откуда):'):
TkComboBox(pub_select='source_changed', sub_items='set_source_combo')
Button('Добавить папку', command=lambda: bus.emit('browse_add_source'))
with f.row('Папки назначения (куда):'):
TkComboBox(pub_select='target_changed', sub_items='set_target_combo')
Button('Добавить папку', command=lambda: bus.emit('browse_add_target'))
with HPaned(expand=True) as main_split:
with main_split.pane('Откуда', weight=1):
with Panel('Исходные Архивы', expand=True):
tree = ArchiveTreeWidget()
bus.subscribe('set_tree_root', tree.set_root_path)
with Panel('Доступные опции модов (из .meta)', expand=True):
flat_tree = FlatModTreeWidget()
bus.subscribe('set_tree_root', flat_tree.set_root_path)
Button('🔄 Просканировать архивы в папке', command=lambda: bus.emit('scan_folder_clicked'))
with main_split.pane('Куда', weight=1):
with Panel('Установленные моды', expand=True):
TkListbox(pub_select='installed_mod_selected', sub_items='update_installed_list')
Button('❌ Удалить выбранный мод', command=lambda: bus.emit('remove_clicked'))
with Panel('Лог операций', expand=True):
logger = Logger()
bus.subscribe('log_write', logger.write)
return app
def create_mock_environment():
base_dir = os.path.join(os.getcwd(), 'mock_stalker_env')
config_path = os.path.join(base_dir, 'config.json')
if os.path.exists(config_path):
return config_path
archives_dir_1 = os.path.join(base_dir, 'Mod_Archives_1')
archives_dir_2 = os.path.join(base_dir, 'Mod_Archives_2')
target1 = os.path.join(base_dir, 'Anomaly_1.5.2_Clear')
target2 = os.path.join(base_dir, 'Anomaly_Custom_Build')
for d in [archives_dir_1, archives_dir_2, target1, target2]:
os.makedirs(d, exist_ok=True)
open(os.path.join(archives_dir_1, 'boomsticks_and_sharpsticks.zip'), 'w').close()
open(os.path.join(archives_dir_1, 'food_drug_and_drinks.7z'), 'w').close()
open(os.path.join(archives_dir_2, 'blind_dogs_retexture.rar'), 'w').close()
with open(config_path, 'w', encoding='utf-8') as f:
json.dump(
{'source_archives': [archives_dir_1, archives_dir_2], 'targets': [target1, target2], 'installed_mods': {}},
f,
indent=4,
ensure_ascii=False,
)
return config_path
def main():
config_path = create_mock_environment()
ctrl = ModManagerController(config_path)
app = build_ui()
app.root.after(100, lambda: bus.emit('app_started'))
app.run()
if __name__ == '__main__':
try:
main()
except KeyboardInterrupt:
print('Closed.')
#!/bin/bash
# Функция для записи логов
log() {
timestamp=$(date +"%Y-%m-%d %H:%M:%S")
echo "$timestamp: $@"
}
# Проверяем, смонтирован ли раздел
if mountpoint -q /storage/games/STALKER; then
# Отмонтируем, если смонтирован
log "Unmounting /storage/games/STALKER"
sudo umount /storage/games/STALKER
fi
# Смонтируем с параметрами overlay
log "Mounting /storage/games/STALKER with overlay"
sudo mount -t overlay -o lowerdir=/storage/games/STALKER-ANOMALY,upperdir=/storage/games/MODS2,workdir=/storage/games/WORK overlay /storage/games/STALKER
# Проверяем результат монтирования
if [ $? -eq 0 ]; then
log "Mounting successful"
else
log "Mounting failed"
fi
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment