# -*- coding: utf-8 -*- """Инстансы (как Prism Launcher): отдельная папка на каждый профиль. Инстанс = папка instances// со своим .minecraft-подобным содержимым (mods, saves, resourcepacks, shaderpacks, config, options.txt и пр.) плюс instance.json с метаданными (имя, версия, лоадер, Java, ОЗУ, иконка). Общие ресурсы (versions, assets, libraries) хранятся в общем .minecraft и переиспользуются всеми инстансами — экономия места. """ import json import os import shutil import time from . import config INSTANCES_DIR = os.path.join(config.APP_DIR, "instances") # Подпапки инстанса (как в Prism) INSTANCE_DIRS = ("mods", "saves", "resourcepacks", "shaderpacks", "config", "screenshots", "datapacks") # Метаданные по умолчанию META_DEFAULTS = { "name": "", "version": "", # id версии MC (например "1.21.4") "loader": "", # "vanilla" | "fabric" | "forge" | "neoforge" | "quilt" "java_path": "", # пусто = авто "ram": 2048, # 0 = авто "jvm_args": "", "win_w": 0, "win_h": 0, "fullscreen": False, "icon": "", # путь к PNG-иконке (пусто = дефолт) "last_played": 0, # timestamp "created": 0, "notes": "", } def _meta_path(inst_dir): return os.path.join(inst_dir, "instance.json") def _game_dir(inst_dir): return os.path.join(inst_dir, ".minecraft") def _ensure_dirs(inst_dir): """Создаёт структуру папок инстанса.""" os.makedirs(inst_dir, exist_ok=True) gd = _game_dir(inst_dir) os.makedirs(gd, exist_ok=True) for sub in INSTANCE_DIRS: os.makedirs(os.path.join(gd, sub), exist_ok=True) def _sanitize_name(name): """Делает имя безопасным для пути.""" bad = '<>:"/\\|?*' out = "".join("_" if c in bad else c for c in name).strip() out = out.rstrip(". ") return out or "Instance" def create(name, version="", loader="vanilla", **overrides): """Создаёт новый инстанс. Возвращает путь к его папке.""" name = _sanitize_name(name) inst_dir = os.path.join(INSTANCES_DIR, name) # если уже есть — добавляем суффикс n = 1 base = name while os.path.exists(inst_dir): n += 1 name = f"{base} ({n})" inst_dir = os.path.join(INSTANCES_DIR, name) _ensure_dirs(inst_dir) meta = dict(META_DEFAULTS) meta["name"] = name meta["version"] = version meta["loader"] = loader meta["created"] = int(time.time()) meta.update(overrides) save_meta(inst_dir, meta) return inst_dir def load_meta(inst_dir): """Читает instance.json (или дефолт, если нет/битый).""" try: with open(_meta_path(inst_dir), encoding="utf-8") as f: data = json.load(f) except Exception: data = {} meta = dict(META_DEFAULTS) if isinstance(data, dict): meta.update(data) if not meta.get("name"): meta["name"] = os.path.basename(inst_dir) return meta def save_meta(inst_dir, meta): """Записывает instance.json.""" try: with open(_meta_path(inst_dir), "w", encoding="utf-8") as f: json.dump(meta, f, indent=2, ensure_ascii=False) except OSError: pass def list_instances(): """Список всех инстансов: [{dir, meta}, ...] отсортированный по имени.""" if not os.path.isdir(INSTANCES_DIR): return [] out = [] for name in sorted(os.listdir(INSTANCES_DIR)): d = os.path.join(INSTANCES_DIR, name) if not os.path.isdir(d): continue if not os.path.isfile(_meta_path(d)): # возможно, это старый .minecraft без instance.json — пропускаем continue out.append({"dir": d, "meta": load_meta(d)}) return out def game_dir(inst_dir): """Путь к .minecraft внутри инстанса.""" return _game_dir(inst_dir) def shared_dir(): """Общий каталог (versions/assets/libraries) — тот же, что и раньше.""" return config.load_settings().get("game_dir", config.DEFAULT_GAME_DIR) def delete(inst_dir, keep_saves=False): """Удаляет инстанс. keep_saves=True — сохраняет папку saves.""" if not os.path.isdir(inst_dir): return False if keep_saves: # перемещаем saves во временное место saves = os.path.join(_game_dir(inst_dir), "saves") tmp = inst_dir + ".deleted" try: os.makedirs(tmp, exist_ok=True) if os.path.isdir(saves): shutil.move(saves, os.path.join(tmp, "saves")) except OSError: pass shutil.rmtree(inst_dir, ignore_errors=True) return True def rename(inst_dir, new_name): """Переименовывает инстанс (папку и meta.name).""" new_name = _sanitize_name(new_name) parent = os.path.dirname(inst_dir) new_dir = os.path.join(parent, new_name) if os.path.exists(new_dir): return None os.rename(inst_dir, new_dir) meta = load_meta(new_dir) meta["name"] = new_name save_meta(new_dir, meta) return new_dir def duplicate(inst_dir): """Дублирует инстанс (без saves).""" meta = load_meta(inst_dir) name = meta.get("name") + " (copy)" new_dir = create(name, meta.get("version", ""), meta.get("loader", "vanilla")) # копируем mods, resourcepacks, config src_gd = _game_dir(inst_dir) dst_gd = _game_dir(new_dir) for sub in ("mods", "resourcepacks", "shaderpacks", "config"): s = os.path.join(src_gd, sub) d = os.path.join(dst_gd, sub) if os.path.isdir(s): try: shutil.copytree(s, d, dirs_exist_ok=True) except OSError: pass return new_dir def export_zip(inst_dir, dest_path): """Экспорт инстанса в zip (без saves). Возвращает путь.""" import zipfile meta = load_meta(inst_dir) name = _sanitize_name(meta.get("name", "instance")) if not dest_path: dest_path = os.path.join(os.path.expanduser("~"), "Desktop", name + ".pixelinst.zip") src_gd = _game_dir(inst_dir) with zipfile.ZipFile(dest_path, "w", zipfile.ZIP_DEFLATED) as zf: zf.writestr("instance.json", json.dumps(meta, indent=2, ensure_ascii=False)) for root, _dirs, files in os.walk(src_gd): rel_root = os.path.relpath(root, src_gd) # пропускаем saves if rel_root == "saves" or rel_root.startswith("saves" + os.sep): continue for fn in files: full = os.path.join(root, fn) arc = os.path.join(".minecraft", rel_root, fn) zf.write(full, arc) return dest_path def import_zip(zip_path): """Импорт инстанса из .zip/.mrpack. Возвращает путь к новому инстансу.""" import zipfile with zipfile.ZipFile(zip_path, "r") as zf: names = zf.namelist() # ищем instance.json meta_raw = None if "instance.json" in names: meta_raw = json.loads(zf.read("instance.json").decode("utf-8")) # mrpack: modrinth.index.json elif "modrinth.index.json" in names: idx = json.loads(zf.read("modrinth.index.json").decode("utf-8")) meta_raw = { "name": idx.get("name", "Modrinth Pack"), "version": idx.get("versionId", ""), "loader": _detect_loader(idx), } if not meta_raw: raise ValueError("неизвестный формат архива") name = _sanitize_name(meta_raw.get("name", "Imported")) inst_dir = create(name, meta_raw.get("version", ""), meta_raw.get("loader", "vanilla")) dst_gd = _game_dir(inst_dir) # распаковываем for n in names: if n.endswith("/"): continue if n == "instance.json" or n == "modrinth.index.json": continue target = n if n.startswith(".minecraft/"): target = n[len(".minecraft/"):] # пропускаем saves при импорте if target == "saves" or target.startswith("saves/"): continue out = os.path.join(dst_gd, target) os.makedirs(os.path.dirname(out), exist_ok=True) with zf.open(n) as src, open(out, "wb") as dst: shutil.copyfileobj(src, dst) # обновляем meta meta = load_meta(inst_dir) meta.update(meta_raw) save_meta(inst_dir, meta) return inst_dir def _detect_loader(idx): """Определяет лоадер из modrinth.index.json.""" for dep in (idx.get("dependencies") or {}): if dep in ("fabric-loader", "quilt-loader"): return dep.split("-")[0] if dep == "forge": return "forge" if dep == "neoforge": return "neoforge" return "vanilla" def mark_played(inst_dir): """Обновляет last_played.""" meta = load_meta(inst_dir) meta["last_played"] = int(time.time()) save_meta(inst_dir, meta) def fmt_last_played(ts): """Человекочитаемое время последнего запуска.""" if not ts: return "—" delta = int(time.time()) - ts if delta < 3600: return f"{max(1, delta // 60)} мин назад" if delta < 86400: return f"{delta // 3600} ч назад" if delta < 86400 * 7: return f"{delta // 86400} дн назад" return time.strftime("%d.%m.%Y", time.localtime(ts))