Last active
June 8, 2026 05:36
-
-
Save Hammer2900/a19fcb01268011f71a1c90fd72edf30b to your computer and use it in GitHub Desktop.
anomaly mod manager utility
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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.') |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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