294 lines
10 KiB
Python
294 lines
10 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Инстансы (как Prism Launcher): отдельная папка на каждый профиль.
|
|
|
|
Инстанс = папка instances/<name>/ со своим .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))
|