Files
PixelLauncher/launcher/instances.py
T

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