PixelLauncher 2.1.3 — инстансы профилей, старт развёрнутым, аккуратные иконки сборок

This commit is contained in:
kosta2014p committed 2026-10-21 22:58:41 +07:00
1 parent 39cf752b7b
commit a04a586038
13 files changed
+1219 -119

No files matched your search

+3 -3
View File
@@ -1,11 +1,11 @@
; PixelLauncher — Inno Setup 6 script
; Сборка: python make_installer.py (или: ISCC.exe installer.iss)
; Результат: PixelLauncherSetup2.1.2.exe в папке проекта.
; Результат: PixelLauncherSetup2.1.3.exe в папке проекта.
; AppId должен оставаться неизменным, иначе обновление появится
; как отдельная программа.
#define MyAppName "PixelLauncher"
#define MyAppVersion "2.1.2"
#define MyAppVersion "2.1.3"
#define MyAppPublisher "SuperBoys"
#define MyAppURL "https://pixellauncher.ru"
#define StageDir "build\stage"
@@ -32,7 +32,7 @@ ArchitecturesAllowed=x64compatible
SetupIconFile={#MyIcon}
UninstallDisplayIcon={app}\launcher.ico
UninstallDisplayName={#MyAppName}
VersionInfoVersion=2.1.2.0
VersionInfoVersion=2.1.3.0
VersionInfoProductVersion={#MyAppVersion}
LZMANumBlockThreads=2
+1 -1
View File
@@ -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
View File
@@ -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:
+4
View File
@@ -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
View File
@@ -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 — напрямую в сеть
+3
View File
@@ -492,6 +492,9 @@ _TABLE = {
"Загрузка…": "Loading…",
"КБ": "KB",
"Б": "B",
"Ключ CurseForge API (необязательно):": "CurseForge API key (optional):",
"Без ключа сборки берутся с зеркала CurseForge.":
"Without a key, modpacks come from the CurseForge mirror.",
}
+293
View File
@@ -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
View File
@@ -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):
+1 -1
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+7 -3
View File
@@ -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(),
+36 -1
View File
@@ -26,7 +26,7 @@ STAGE = os.path.join(BUILD, "stage")
ISCC = r"C:\Program Files (x86)\Inno Setup 6\ISCC.exe"
PYVER = "%d.%d.%d" % sys.version_info[:3]
APP_VERSION = "2.1.2" # должен совпадать с LAUNCHER_VERSION в launcher/__init__.py
APP_VERSION = "2.1.3" # должен совпадать с LAUNCHER_VERSION в launcher/__init__.py
SETUP_NAME = "PixelLauncherSetup%s.exe" % APP_VERSION
@@ -83,6 +83,38 @@ def build_stage():
copied.append("tkinter/")
log("tkinter/Tcl: " + ", ".join(copied) + " + tcl/")
# --- Pillow (опционально) — сглаженные иконки сборок; если её нет,
# лаунчер переключается на штатный Tk-путь
sites = [os.path.join(base, "Lib", "site-packages")]
try:
import site as _site
us = _site.getusersitepackages()
sites.extend(us if isinstance(us, (list, tuple)) else [us])
except Exception:
pass
staged = []
for site_dir in sites:
if not os.path.isdir(site_dir):
continue
for name in sorted(os.listdir(site_dir)):
if name != "PIL" and not name.lower().startswith("pillow"):
continue
src = os.path.join(site_dir, name)
dst = os.path.join(STAGE, name)
if os.path.isdir(src):
if os.path.isdir(dst):
shutil.rmtree(dst, ignore_errors=True)
shutil.copytree(src, dst,
ignore=shutil.ignore_patterns("__pycache__",
"*.pyc"))
elif os.path.isfile(src):
shutil.copy2(src, dst)
staged.append(name)
if staged:
break
log("Pillow: " + (", ".join(staged) if staged
else "не найдена — иконки без сглаживания"))
# ._pth: гарантируем точку в пути (иначе import launcher не найдёт)
pth = [f for f in os.listdir(STAGE) if f.endswith("._pth")][0]
pth_path = os.path.join(STAGE, pth)
@@ -151,6 +183,9 @@ if __name__ == "__main__":
"import tkinter,ssl,urllib.request,json,zipfile;"
"r=tkinter.Tk(); r.withdraw(); r.destroy();"
"import launcher.config, launcher.http, launcher.manifest;"
"import importlib,importlib.util;"
"importlib.import_module('PIL.Image')"
" if importlib.util.find_spec('PIL') else None;"
"print('stage ok')"],
capture_output=True, text=True, encoding="utf-8", errors="replace",
cwd=STAGE)
+3
View File
@@ -1,3 +1,6 @@
# Внешние зависимости не требуются.
# Достаточно Python 3.8+ (всё реализовано на стандартной библиотеке:
# tkinter, urllib, json, zipfile, concurrent.futures).
# Если доступна Pillow, лаунчер использует её для сглаженных иконок сборок
# (установщик PixelLauncher Pillow включает автоматически); без Pillow
# иконки рисуются штатным Tk — работает в любом случае.