PixelLauncher 2.1.3 — инстансы профилей, старт развёрнутым, аккуратные иконки сборок
This commit is contained in:
1 parent
39cf752b7b
commit
a04a586038
13 files changed
+1219
-119
No files matched your search
@@ -1,4 +1,4 @@
|
||||
"""PixelLauncher — lightweight Minecraft launcher (offline mode)."""
|
||||
|
||||
LAUNCHER_NAME = "PixelLauncher"
|
||||
LAUNCHER_VERSION = "2.1.2"
|
||||
LAUNCHER_VERSION = "2.1.3"
|
||||
+8
-6
@@ -32,11 +32,13 @@ def _split_loader(lid):
|
||||
def read_manifest(path):
|
||||
"""Читает manifest.json. Возвращает (данные, mc, loader, loader_ver)."""
|
||||
with zipfile.ZipFile(path) as z:
|
||||
names = set(z.namelist())
|
||||
if "manifest.json" not in names:
|
||||
raise RuntimeError("в архиве нет manifest.json")
|
||||
with z.open("manifest.json") as f:
|
||||
data = json.loads(f.read().decode("utf-8"))
|
||||
return _read_manifest(z)
|
||||
|
||||
|
||||
def _read_manifest(zf):
|
||||
"""То же, но из уже открытого архива."""
|
||||
with zf.open("manifest.json") as f:
|
||||
data = json.loads(f.read().decode("utf-8"))
|
||||
minecraft = data.get("minecraft") or {}
|
||||
mc = minecraft.get("version") or ""
|
||||
loader, loader_ver = "", ""
|
||||
@@ -123,7 +125,7 @@ def install(game_dir, pack_path, log=None, progress=None, cancel=None):
|
||||
with zipfile.ZipFile(pack_path) as zf:
|
||||
names = set(zf.namelist())
|
||||
if "manifest.json" in names:
|
||||
data, mc, loader, loader_ver = read_manifest(pack_path)
|
||||
data, mc, loader, loader_ver = _read_manifest(zf)
|
||||
mod_list = []
|
||||
for entry in (data.get("files") or []):
|
||||
if entry.get("required") is False:
|
||||
|
||||
@@ -38,6 +38,8 @@ DEFAULTS = {
|
||||
# зеркала скачивания (пустые списки = официальные серверы Mojang)
|
||||
"asset_mirrors": [],
|
||||
"lib_mirrors": [],
|
||||
# ключ CurseForge API (необязателен: есть зеркало без ключа)
|
||||
"cf_api_key": "",
|
||||
# третья волна
|
||||
"fav_versions": [], # закреплённые версии (id)
|
||||
"profiles": [], # профили запуска (см. ui._open_profiles)
|
||||
@@ -87,6 +89,8 @@ def _migrate(s):
|
||||
s[key] = []
|
||||
if not isinstance(s.get("accent"), str) or not s["accent"].startswith("#"):
|
||||
s["accent"] = DEFAULTS["accent"]
|
||||
if not isinstance(s.get("cf_api_key"), str):
|
||||
s["cf_api_key"] = ""
|
||||
if s.get("theme") not in ("dark", "light"):
|
||||
s["theme"] = "dark"
|
||||
|
||||
|
||||
+3
-1
@@ -13,7 +13,9 @@ import struct
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
USER_AGENT = "PixelLauncher/2.1.2 (lightweight Minecraft launcher)"
|
||||
from . import LAUNCHER_VERSION
|
||||
|
||||
USER_AGENT = "PixelLauncher/%s (lightweight Minecraft launcher)" % LAUNCHER_VERSION
|
||||
ATTEMPTS = 4
|
||||
|
||||
_opener = None # настроенный opener (прокси) или None — напрямую в сеть
|
||||
|
||||
@@ -492,6 +492,9 @@ _TABLE = {
|
||||
"Загрузка…": "Loading…",
|
||||
"КБ": "KB",
|
||||
"Б": "B",
|
||||
"Ключ CurseForge API (необязательно):": "CurseForge API key (optional):",
|
||||
"Без ключа сборки берутся с зеркала CurseForge.":
|
||||
"Without a key, modpacks come from the CurseForge mirror.",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
# -*- 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))
|
||||
+1
-5
@@ -1,12 +1,8 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Утилиты для работы с модпаками Modrinth/CurseForge."""
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
from . import forge, http, mods
|
||||
from .java_fetch import _safe_join
|
||||
from . import modrinth, curseforge
|
||||
from . import curseforge, modrinth
|
||||
|
||||
|
||||
def install_pack_mrpack_url(game_dir, url, log=None, progress=None, cancel=None):
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"""Автообновление лаунчера: проверка манифеста, скачивание установщика.
|
||||
|
||||
Манифест на сервере — /update.json:
|
||||
{"version": "2.1.2", "url": "/download/PixelLauncherSetup2.1.2.exe",
|
||||
{"version": "2.1.3", "url": "/download/PixelLauncherSetup2.1.3.exe",
|
||||
"sha256": "…", "size": 15418378, "notes": "…"}
|
||||
url относительный (база — UPDATE_BASE), sha256/size необязательны,
|
||||
но sha256 проверяется, если указан.
|
||||
|
||||
+856
-98
File diff suppressed because it is too large.
Load diff
+7
-3
@@ -183,12 +183,16 @@ class Version:
|
||||
return os.pathsep.join(entries)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def build_command(self, java, username, uuid_hex, ram_mb, extra_jvm=""):
|
||||
def build_command(self, java, username, uuid_hex, ram_mb, extra_jvm="",
|
||||
game_directory=None):
|
||||
# Общие ресурсы (libraries/assets/versions) берём из self.game_dir,
|
||||
# а каталог игры (mods/saves/config) можно переопределить — так
|
||||
# работают инстансы: общий движок, разные папки контента.
|
||||
gd = self.game_dir
|
||||
subst = {
|
||||
"auth_player_name": username,
|
||||
"version_name": self.id,
|
||||
"game_directory": gd,
|
||||
"game_directory": game_directory or gd,
|
||||
"assets_root": self.assets_dir(),
|
||||
"assets_index_name": self.asset_index.get("id", ""),
|
||||
"auth_uuid": uuid_hex,
|
||||
@@ -198,7 +202,7 @@ class Version:
|
||||
"version_type": self.type,
|
||||
"natives_directory": self.natives_dir(),
|
||||
"launcher_name": "PixelLauncher",
|
||||
"launcher_version": "2.1.2",
|
||||
"launcher_version": sys.modules[__package__].LAUNCHER_VERSION,
|
||||
"classpath": self.classpath_str(),
|
||||
"classpath_separator": os.pathsep,
|
||||
"library_directory": self.libraries_dir(),
|
||||
|
||||
Reference in new issue
Block a user