Files
PixelLauncher/launcher/ui.py
T

5650 lines
246 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""PixelLauncher — компактный тёмный лаунчер в духе LiteLauncher.
Минималистичный дизайн: узкая панель с логотипом и ником сверху, пиксельный
пейзаж-баннер по центру, большая зелёная кнопка «ИГРАТЬ» и пилюля с версией.
Без сторонних библиотек (tkinter).
"""
import ctypes
import json
import math
import os
import queue
import random
import re
import shutil
import subprocess
import sys
import tempfile
import threading
import time
import tkinter as tk
import webbrowser
from ctypes import wintypes
from tkinter import filedialog, messagebox, ttk
from . import config, crash as crash_mod, downloader, forge as forge_mod, http
from . import curseforge as curseforge_mod
from . import java as java_mod, java_fetch
from . import launch as launch_mod
from . import manifest as manifest_mod
from . import mods as mods_mod
from . import modrinth as modrinth_mod
from . import mrpack as mrpack_mod
from . import packs as packs_mod
from . import plugins as plugins_mod
from . import protocol as protocol_mod
from . import selfupdate
from . import stats as stats_mod
from . import tray as tray_mod
from . import version as version_mod
from . import worlds as worlds_mod
from .i18n import T, set_language
from . import LAUNCHER_VERSION
CREATE_NO_WINDOW = 0x08000000 if os.name == "nt" else 0
# ------------------------------------------------------------------ палитра
BG = "#141414"
BAR = "#1b1b1b"
BAR_BD = "#2c2c2c"
PANEL = "#1e1e1e"
INPUT = "#101010"
INPUT_BD = "#333333"
HOVER = "#2a2a2a"
TEXT = "#f0f0f0"
DIM = "#8a8a8a"
FAINT = "#5c5c5c"
GREEN = "#6fae4a"
GREEN_HI = "#7fc257"
GREEN_LO = "#40702c"
GREEN_BD = "#2c4a1e"
OK = "#7fc257"
ERR = "#e0655f"
WARN = "#d8a13a"
PILL = "#242424"
PILL_BD = "#3a3a3a"
THEME = "dark" # текущая тема: "dark" или "light"
# Хром окна по темам (фоны/границы/текст). Акцент (GREEN*) считается
# отдельно через set_accent и от темы не зависит.
THEMES = {
"dark": {"BG": "#141414", "BAR": "#1b1b1b", "BAR_BD": "#2c2c2c",
"PANEL": "#1e1e1e", "INPUT": "#101010", "INPUT_BD": "#333333",
"HOVER": "#2a2a2a", "TEXT": "#f0f0f0", "DIM": "#8a8a8a",
"FAINT": "#5c5c5c", "PILL": "#242424", "PILL_BD": "#3a3a3a",
"OK": "#7fc257", "ERR": "#e0655f", "WARN": "#d8a13a"},
"light": {"BG": "#ededed", "BAR": "#f9f9f9", "BAR_BD": "#d4d4d4",
"PANEL": "#f6f6f6", "INPUT": "#ffffff", "INPUT_BD": "#c9c9c9",
"HOVER": "#e1e1e1", "TEXT": "#1c1c1c", "DIM": "#6b6b6b",
"FAINT": "#9b9b9b", "PILL": "#e7e7e7", "PILL_BD": "#d0d0d0",
"OK": "#4e9a34", "ERR": "#cc4038", "WARN": "#a8791a"},
}
def set_theme(name):
"""Меняет тему оформления (тёмная/светлая).
Как и set_accent — читается в момент отрисовки, поэтому после вызова
нужно пересобрать виджеты: self.configure(bg=BG) + _rebuild_ui().
"""
global THEME, BG, BAR, BAR_BD, PANEL, INPUT, INPUT_BD, HOVER, \
TEXT, DIM, FAINT, PILL, PILL_BD, OK, ERR, WARN
THEME = name if name in THEMES else "dark"
t = THEMES[THEME]
BG, BAR, BAR_BD = t["BG"], t["BAR"], t["BAR_BD"]
PANEL, INPUT, INPUT_BD = t["PANEL"], t["INPUT"], t["INPUT_BD"]
HOVER, TEXT, DIM, FAINT = t["HOVER"], t["TEXT"], t["DIM"], t["FAINT"]
PILL, PILL_BD = t["PILL"], t["PILL_BD"]
OK, ERR, WARN = t["OK"], t["ERR"], t["WARN"]
TYPE_RU = {"release": "релиз", "snapshot": "снапшот",
"old_alpha": "old alpha", "old_beta": "old beta"}
WIN_W, WIN_H = 660, 640
BANNER_W, BANNER_H = 560, 300
def mix(c1, c2, t):
a = [int(c1[i:i + 2], 16) for i in (1, 3, 5)]
b = [int(c2[i:i + 2], 16) for i in (1, 3, 5)]
return "#%02x%02x%02x" % tuple(round(a[i] + (b[i] - a[i]) * t)
for i in range(3))
def set_accent(hex_color):
"""Меняет акцентный цвет (кнопка ИГРАТЬ, прогресс, выделение списков).
Читается в момент отрисовки, поэтому достаточно перерисовать виджеты.
"""
global GREEN, GREEN_HI, GREEN_LO, GREEN_BD, ACCENT
ACCENT = hex_color
GREEN = hex_color
GREEN_HI = mix(hex_color, "#ffffff", 0.18)
GREEN_LO = mix(hex_color, "#000000", 0.42)
GREEN_BD = mix(hex_color, "#000000", 0.62)
def round_rect(c, x0, y0, x1, y1, r, fill, outline="", width=1):
"""Скруглённый прямоугольник на Canvas."""
c.create_polygon(x0 + r, y0, x1 - r, y0, x1, y0, x1, y0 + r,
x1, y1 - r, x1, y1, x1 - r, y1, x0 + r, y1,
x0, y1, x0, y1 - r, x0, y0 + r, x0, y0,
smooth=True, fill=fill, outline=outline, width=width)
def round_mask(cv, w, h, r, bg, seg=8):
"""Закрашивает углы холста цветом фона — эффект скруглённого баннера."""
def arc(cx, cy, a0, a1):
pts = []
for i in range(seg + 1):
a = math.radians(a0 + (a1 - a0) * i / seg)
pts += [cx + r * math.cos(a), cy + r * math.sin(a)]
return pts
for pts in ([0, 0] + arc(r, r, -90, -180),
[w, 0] + arc(w - r, r, -90, 0),
[0, h] + arc(r, h - r, 180, 90),
[w, h] + arc(w - r, h - r, 0, 90)):
cv.create_polygon(pts, fill=bg, outline="")
# ------------------------------------------- пиксельный шрифт 5x7
PIXEL_FONT = {
"A": (".###.", "#...#", "#...#", "#####", "#...#", "#...#", "#...#"),
"C": (".###.", "#...#", "#....", "#....", "#....", "#...#", ".###."),
"E": ("#####", "#....", "#....", "####.", "#....", "#....", "#####"),
"H": ("#...#", "#...#", "#...#", "#####", "#...#", "#...#", "#...#"),
"I": ("#####", "..#..", "..#..", "..#..", "..#..", "..#..", "#####"),
"L": ("#....", "#....", "#....", "#....", "#....", "#....", "#####"),
"N": ("#...#", "##..#", "#.#.#", "#.#.#", "#..##", "#...#", "#...#"),
"P": ("####.", "#...#", "#...#", "####.", "#....", "#....", "#...."),
"R": ("####.", "#...#", "#...#", "####.", "#.#..", "#..#.", "#...#"),
"U": ("#...#", "#...#", "#...#", "#...#", "#...#", "#...#", ".###."),
"X": ("#...#", "#...#", ".#.#.", "..#..", ".#.#.", "#...#", "#...#"),
"И": ("#...#", "#...#", "#..##", "#.#.#", "##..#", "#...#", "#...#"),
"Г": ("#####", "#....", "#....", "#....", "#....", "#....", "#...."),
"Т": ("#####", "..#..", "..#..", "..#..", "..#..", "..#..", "..#.."),
"Ь": ("#....", "#....", "#....", "#....", "####.", "#...#", "####."),
"А": (".###.", "#...#", "#...#", "#####", "#...#", "#...#", "#...#"),
"Р": ("####.", "#...#", "#...#", "####.", "#....", "#....", "#...."),
}
def pixel_text_width(text, scale):
return max(0, len(text) * 6 * scale - scale)
def draw_pixel_text(cv, x, y, text, scale, fill, shadow=None, sdx=3, sdy=3):
"""Рисует текст пиксельным шрифтом 5x7 (по клетке на пиксель)."""
if shadow:
_blit_text(cv, x + sdx, y + sdy, text, scale, shadow)
return _blit_text(cv, x, y, text, scale, fill)
def _blit_text(cv, x, y, text, scale, fill):
ox = x
for ch in text.upper():
glyph = PIXEL_FONT.get(ch)
if glyph is None: # пробел и неизвестные — просто отступ
ox += 6 * scale
continue
for ry, row in enumerate(glyph):
for rx, c in enumerate(row):
if c == "#":
px0 = ox + rx * scale
py0 = y + ry * scale
cv.create_rectangle(px0, py0, px0 + scale, py0 + scale,
fill=fill, outline="")
ox += 6 * scale
return pixel_text_width(text, scale)
# ============================================================ пиксель-арт
def draw_scene(cv, w, h, night=False, scene=None):
"""Пиксельный пейзаж. scene: day/night/nether/cave (None — по night)."""
if scene is None:
scene = "night" if night else "day"
if scene == "nether":
return _draw_nether(cv, w, h)
if scene == "cave":
return _draw_cave(cv, w, h)
night = scene == "night"
cell = 10
cols, rows = w // cell, h // cell
tint = (lambda c: mix(c, "#0a1020", 0.55)) if night else (lambda c: c)
def g(gx, gy, color, gx2=None, gy2=None):
cv.create_rectangle(gx * cell, gy * cell,
(gx2 if gx2 is not None else gx + 1) * cell,
(gy2 if gy2 is not None else gy + 1) * cell,
fill=color, outline="")
def px(gx, gy, x0, y0, x1, y1, color):
"""Пиксель внутри клетки (доли 0..1)."""
cv.create_rectangle(gx * cell + x0, gy * cell + y0,
gx * cell + x1, gy * cell + y1,
fill=color, outline="")
ground = 19 # верхний ряд земли
# Небо — градиент полосами во всю ширину
if night:
sky_top, sky_bot = "#0f1b33", "#1c2f52"
else:
sky_top, sky_bot = "#6fb6e6", "#b6e0f2"
for gy in range(ground):
color = mix(sky_top, sky_bot, gy / max(1, ground - 1))
cv.create_rectangle(0, gy * cell, w, (gy + 1) * cell,
fill=color, outline="")
# Звёзды (только ночью)
if night:
for sx, sy in ((5, 2), (12, 1), (18, 4), (24, 2), (21, 1), (9, 6),
(40, 1), (44, 3), (50, 2), (54, 5), (47, 7), (26, 5)):
g(sx, sy, "#cfd8ea")
# Солнце с лучами / луна
if night:
for gx in range(32, 37):
for gy in range(1, 6):
if gx in (32, 36) or gy in (1, 5):
g(gx, gy, "#a9b4d2")
for gx in range(33, 36):
for gy in range(2, 5):
g(gx, gy, "#f4f7ff")
for sx, sy in ((31, 2), (31, 4), (37, 2), (37, 4)):
g(sx, sy, "#8d99bd")
else:
for gx in range(30, 37):
for gy in range(0, 7):
if gx in (30, 36) or gy in (0, 6):
g(gx, gy, "#e3f4fe") # ореол
else:
g(gx, gy, "#b8e2f7")
for gx in range(32, 35):
for gy in range(2, 5):
g(gx, gy, "#ffffff") # ядро
for sx, sy in ((29, 0), (29, 6), (37, 0), (37, 6), (29, 3), (37, 3)):
g(sx, sy, "#ffffff") # лучи
# Облака
def cloud(cx, cy):
for dx, dy in ((0, 0), (1, 0), (2, 0), (3, 0), (1, -1), (2, -1),
(5, 0), (6, 0), (6, -1)):
g(cx + dx, cy + dy, tint("#ffffff"))
cloud(12, 2)
cloud(44, 4)
# Дальние горы
for gx in range(cols):
top = 11 + ((gx * 7) % 5)
c1 = "#b3d0e3" if (gx + top) % 2 else "#a3c3d9"
for gy in range(top, 16):
g(gx, gy, tint(c1))
# Низкие кусты-холмы на горизонте
for gx in range(cols):
top = 16 + ((gx * 5) % 3)
g(gx, top, tint("#3a7a34"))
if top + 1 < ground:
g(gx, top + 1, tint("#347030"))
for gy in range(top + 2, ground):
g(gx, gy, tint("#2f682c"))
# Поверхность: песчаная дорожка слева, трава справа
for gx in range(cols):
if gx <= 15:
g(gx, ground, tint("#d9c48b"))
px(gx, ground, 0, 0, 10, 3, tint("#e8d8a8"))
else:
g(gx, ground, tint("#6fae4a"))
px(gx, ground, 0, 0, 10, 3, tint("#8ec96a"))
# Земля: грунт с крапинами и вкраплениями камня
dirt, dirt_d = "#8a5a33", "#6b4a2f"
stone, stone_d = "#6f6f6f", "#5a5a5a"
for gx in range(cols):
for gy in range(ground + 1, rows - 3):
base = dirt if (gx + gy) % 7 else dirt_d
g(gx, gy, tint(base))
if (gx * 7 + gy * 13) % 11 == 0:
px(gx, gy, 3, 4, 6, 7, tint(dirt_d))
if (gx * 5 + gy * 3) % 23 == 0:
px(gx, gy, 5, 2, 8, 5, tint(stone))
for gy in range(rows - 3, rows):
base = stone if (gx * 3 + gy) % 5 else stone_d
g(gx, gy, tint(base))
# Каменный угол справа внизу (как на референсе)
for gx in range(44, cols):
for gy in range(rows - 5, rows - 3):
g(gx, gy, tint(stone if (gx + gy) % 3 else stone_d))
# Пруд слева
for gx in range(4, 14):
g(gx, ground + 1, tint("#3f7fd0"))
g(gx, ground + 2, tint("#3f7fd0"))
if (gx * 3) % 4 == 0:
px(gx, ground + 1, 1, 3, 7, 5, tint("#67a8e8"))
if gx in (4, 13):
g(gx, ground + 1, tint(dirt_d))
g(gx, ground + 2, tint(dirt_d))
# Дерево (большая крона справа)
trunk, trunk_d = "#6b4a2f", "#54391f"
leaf, leaf_h, leaf_d = "#4f8f3f", "#63a952", "#3f7a32"
for gy in range(14, ground):
g(35, gy, tint(trunk))
g(36, gy, tint(trunk_d))
for gx in range(30, 43):
for gy in range(7, 14):
corner = (gx in (30, 42) and gy in (7, 13))
g(gx, gy, tint(leaf_d if corner else leaf))
for gx, gy in ((31, 8), (38, 9), (41, 11), (34, 12), (40, 8), (36, 10)):
g(gx, gy, tint(leaf_h))
# Сундук
for gx in range(20, 23):
g(gx, 17, tint("#c8913f"))
g(gx, 18, tint("#a87433"))
g(20, 17, tint("#8a5a33"))
g(22, 17, tint("#8a5a33"))
px(21, 17, 3, 3, 7, 8, tint("#e8c84a"))
# Крипер (только голова над травой)
cr, cr_d = "#5da84e", "#4c8a40"
for gx in range(46, 50):
for gy in range(16, 19):
g(gx, gy, tint(cr_d if (gx + gy) % 5 == 0 else cr))
for gx, gy in ((46, 16), (49, 16), (47, 17), (48, 17),
(47, 18), (48, 18)):
g(gx, gy, "#1c1c1c")
# Цветы и травинки
for gx, color in ((17, "#d94040"), (25, "#e8d24a"), (41, "#d94040")):
px(gx, 18, 3, 0, 7, 4, tint(color))
px(gx, 18, 4, 4, 6, 10, tint("#4f8f3f"))
for gx in (16, 27, 43, 52):
px(gx, 18, 2, 4, 5, 10, tint("#8ec96a"))
px(gx, 18, 6, 6, 8, 10, tint("#6fae4a"))
def _scene_helpers(cv, w, h):
"""Общие помощники для пиксельных сцен: g() и px()."""
cell = 10
cols, rows = w // cell, h // cell
def g(gx, gy, color, gx2=None, gy2=None):
cv.create_rectangle(gx * cell, gy * cell,
(gx2 if gx2 is not None else gx + 1) * cell,
(gy2 if gy2 is not None else gy + 1) * cell,
fill=color, outline="")
def px(gx, gy, x0, y0, x1, y1, color):
cv.create_rectangle(gx * cell + x0, gy * cell + y0,
gx * cell + x1, gy * cell + y1,
fill=color, outline="")
return g, px, cell, cols, rows
def _draw_nether(cv, w, h):
"""Незер: багровое небо, лава, светокамень, порталь, огни."""
g, px, cell, cols, rows = _scene_helpers(cv, w, h)
ground = 15
# Небо — багровый градиент
top, bot = "#3d1515", "#7a2c2c"
for gy in range(ground + 4):
col = mix(top, bot, gy / max(1, ground + 3))
cv.create_rectangle(0, gy * cell, w, (gy + 1) * cell,
fill=col, outline="")
# Блики жара у горизонта
for gx in range(cols):
if (gx * 5) % 7 < 2:
px(gx, ground - 2, 2, 4, 8, 7, "#8f3a34")
# Потолок: сталактиты из незеррека
nr, nr_d = "#5c2323", "#471a1a"
for gx in range(cols):
depth = 1 + ((gx * 7) % 3)
for gy in range(depth):
g(gx, gy, nr_d if (gx + gy) % 3 else nr)
if (gx * 11) % 13 == 0:
g(gx, depth, nr_d)
# Лавовое озеро снизу
for gy in range(ground + 2, rows):
for gx in range(cols):
if gy == ground + 2 and (gx * 3) % 5:
continue # край озера неровный
c = "#e0641e" if (gx + gy) % 3 else "#c2521a"
g(gx, gy, c)
for gx in range(cols):
if (gx * 7) % 6 == 0:
px(gx, ground + 2, 1, 0, 7, 4, "#ffb52e")
if (gx * 3) % 11 == 0:
px(gx, ground + 3, 4, 3, 9, 7, "#ffd05a")
# Берег: незеррек с крапинами
for gx in range(cols):
if (gx * 3) % 5 < 2:
continue
g(gx, ground, "#5c2323")
g(gx, ground + 1, "#471a1a" if gx % 2 else "#5c2323")
if (gx * 7 + 3) % 9 == 0:
px(gx, ground, 3, 3, 6, 6, "#3d1515")
# Песок душ слева
for gx in range(3, 11):
g(gx, ground, "#8e7a5a")
if gx % 2:
px(gx, ground, 2, 2, 5, 5, "#7a6849")
# червонный нарост
if gx in (5, 7, 9):
g(gx, ground - 1, "#a03030")
px(gx, ground - 1, 3, 0, 6, 4, "#c04040")
# Столбы базальта
for gx0, hgt in ((14, 5), (30, 3)):
for gx in (gx0, gx0 + 1):
for gy in range(ground - hgt, ground):
g(gx, gy, "#3a3a3f" if (gx + gy) % 3 else "#2e2e33")
# Светокамень под потолком + ореол
for cx, cy in ((22, 3), (40, 2), (52, 4)):
for gx in range(cx, cx + 3):
for gy in range(cy, cy + 2):
g(gx, gy, "#f5d77a" if (gx + gy) % 2 else "#e0b95f")
for dx in (-1, 3):
g(cx + dx, cy, "#b98f45")
g(cx + 1, cy - 1, "#d4a954")
g(cx + 1, cy + 2, "#b98f45")
# Огни на берегу
for gx in (18, 35, 47):
px(gx, ground - 1, 3, 2, 6, 10, "#ff9a2e")
px(gx, ground - 1, 4, 4, 5, 8, "#ffd05a")
# Портал: обсидиановая рама с фиолетовым полем
for gx in range(42, 46):
g(gx, 10, "#1d1130")
g(gx, 14, "#1d1130")
for gy in range(10, 15):
g(42, gy, "#1d1130")
g(45, gy, "#1d1130")
for gx in range(43, 45):
for gy in range(11, 14):
g(gx, gy, "#8a3ff0" if (gx + gy) % 2 else "#6e2fd0")
px(43, 12, 1, 1, 4, 4, "#c79aff")
px(44, 11, 5, 5, 8, 8, "#b07af5")
def _draw_cave(cv, w, h):
"""Пещера: тёмный камень, руды, лава, факел со светом."""
g, px, cell, cols, rows = _scene_helpers(cv, w, h)
top, bot = "#1f2226", "#33383e"
def stone_at(gy):
return mix(top, bot, gy / max(1, rows - 1))
# Каменная тьма полосами
for gy in range(rows):
cv.create_rectangle(0, gy * cell, w, (gy + 1) * cell,
fill=stone_at(gy), outline="")
# Потолок со сталактитами
for gx in range(cols):
depth = 1 + ((gx * 5) % 4)
for gy in range(depth):
g(gx, gy, "#3a3f45" if (gx + gy) % 3 else "#2b2f34")
# Крупный каменный крап на стенах
for gx in range(cols):
for gy in range(rows):
if (gx * 7 + gy * 13) % 17 == 0:
px(gx, gy, 2, 2, 6, 6, "#454b52" if gy > rows // 2
else "#262a2f")
floor = 16
# Пол
for gx in range(cols):
for gy in range(floor, rows):
c = "#4a4f55" if (gx + gy) % 4 else "#3d4248"
g(gx, gy, c)
# Лавовый ручей слева внизу
for gx in range(0, 13):
for gy in range(floor + 1, rows):
g(gx, gy, "#e0641e" if (gx + gy) % 3 else "#c2521a")
for gx in (2, 6, 10):
px(gx, floor + 1, 1, 0, 7, 4, "#ffb52e")
g(13, floor + 1, "#3d4248")
g(13, floor + 2, "#3d4248")
# Руды: уголь, железо, золото, алмаз
ores = (
(((16, 11), (17, 11), (16, 12)), "#2b2b2b", "#1e1e1e"),
(((33, 8), (34, 8), (33, 9)), "#c98d6b", "#a06b4c"),
(((47, 12), (48, 12), (48, 13)), "#f5cf5a", "#c9a53c"),
(((27, 13), (28, 13), (27, 14)), "#5ee8e0", "#3cb8b0"),
)
for spots, c1, c2 in ores:
for i, (gx, gy) in enumerate(spots):
g(gx, gy, c1)
px(gx, gy, 1, 1, 4, 4, c2)
px(spots[0][0], spots[0][1], 6, 5, 9, 9, c2)
# Факел на стене со световым ореолом
fx, fy = 40, 10
for dx in range(-4, 5):
for dy in range(-4, 6):
dist = abs(dx) + abs(dy)
if dist == 0 or dist > 7:
continue
gx, gy = fx + dx, fy + dy
if not (0 <= gx < cols and 0 <= gy < rows):
continue
t = 0.34 * (8 - dist) / 7
g(gx, gy, mix(stone_at(gy), "#ffe3a0", t))
px(fx, fy, 3, 4, 7, 10, "#8a6a3f")
px(fx, fy, 4, 1, 6, 5, "#ffcf4a")
px(fx, fy, 4, 3, 6, 5, "#ff9a2e")
# Грибы и травинки на полу
for gx, c in ((20, "#5aa0c0"), (22, "#c05a5a"), (36, "#5aa0c0")):
px(gx, floor, 3, 3, 7, 6, c)
px(gx, floor, 4, 6, 6, 10, "#d8d8d8")
def _skin_parts(full=True):
"""Прямоугольники скина для фронтального вида.
(sx, sy, ex, ey, dx, dy): источник в текстуре и положение на канве
(в клетках). full=False — старый формат 64x32 (без наложений).
"""
if full:
return [
(8, 8, 16, 16, 4, 0), (40, 8, 48, 16, 4, 0), # голова + шляпа
(44, 20, 48, 32, 0, 8), (44, 36, 48, 48, 0, 8), # правая рука
(36, 52, 40, 64, 12, 8), (52, 52, 56, 64, 12, 8), # левая рука
(20, 20, 28, 32, 4, 8), (20, 36, 28, 48, 4, 8), # тело + куртка
(4, 20, 8, 32, 4, 20), (4, 36, 8, 48, 4, 20), # правая нога
(20, 52, 24, 64, 8, 20), (4, 52, 8, 64, 8, 20), # левая нога
]
return [
(8, 8, 16, 16, 4, 0),
(44, 20, 48, 32, 0, 8), (20, 20, 28, 32, 4, 8),
(44, 20, 48, 32, 12, 8),
(4, 20, 8, 32, 4, 20), (4, 20, 8, 32, 8, 20),
]
def _png_rgba(path):
"""Минимальный декодер PNG (8 бит, RGB/RGBA, без interlace).
Tk умеет показывать PNG, но photo get отдаёт только RGB — альфа
теряется, а у скинов прозрачный оверлей лежит как (0,0,0). Декодируем
сами: (ширина, высота, байты RGBA) либо None, если формат не поддержан.
"""
try:
import struct
import zlib
data = open(path, "rb").read()
if data[:8] != b"\x89PNG\r\n\x1a\n":
return None
pos, idat, meta = 8, [], None
while pos + 8 <= len(data):
ln = struct.unpack(">I", data[pos:pos + 4])[0]
typ = data[pos + 4:pos + 8]
chunk = data[pos + 8:pos + 8 + ln]
if typ == b"IHDR":
meta = struct.unpack(">IIBBBBB", chunk)
elif typ == b"IDAT":
idat.append(chunk)
elif typ == b"IEND":
break
pos += 12 + ln
if not meta:
return None
w, h, depth, ctype, _c, _f, inter = meta
if depth != 8 or inter != 0 or ctype not in (2, 6):
return None
ch = 3 if ctype == 2 else 4
raw = zlib.decompress(b"".join(idat))
stride = w * ch
out = bytearray()
prev = bytearray(stride)
p = 0
for _y in range(h):
ft = raw[p] # фильтр строки: 0..4
p += 1
line = bytearray(raw[p:p + stride])
p += stride
if ft == 1:
for x in range(ch, stride):
line[x] = (line[x] + line[x - ch]) & 255
elif ft == 2:
for x in range(stride):
line[x] = (line[x] + prev[x]) & 255
elif ft == 3:
for x in range(stride):
a = line[x - ch] if x >= ch else 0
line[x] = (line[x] + ((a + prev[x]) >> 1)) & 255
elif ft == 4:
for x in range(stride):
a = line[x - ch] if x >= ch else 0
c = prev[x - ch] if x >= ch else 0
b = prev[x]
pp = a + b - c
pa, pb, pc = abs(pp - a), abs(pp - b), abs(pp - c)
pr = a if (pa <= pb and pa <= pc) else (
b if pb <= pc else c)
line[x] = (line[x] + pr) & 255
prev = line
if ch == 3: # RGB → RGBA
for x in range(w):
out += line[x * 3:x * 3 + 3] + b"\xff"
else:
out += line
return w, h, bytes(out)
except Exception: # noqa: BLE001 — любой битый файл
return None
# ============================================================ виджеты
class Icon_label(tk.Label):
"""Невидимая кнопка-иконка в панели окна."""
def __init__(self, master, glyph, command, danger=False):
super().__init__(master, text=glyph, bg=BAR, fg=DIM,
font=("Segoe UI Symbol", 11), width=3, cursor="hand2")
self._cmd = command
self._danger = danger
self.bind("<Enter>", self._enter)
self.bind("<Leave>", self._leave)
self.bind("<Button-1>", self._click)
def _enter(self, _=None):
self.configure(bg="#e81123" if self._danger else HOVER,
fg="#ffffff" if self._danger else TEXT)
def _leave(self, _=None):
self.configure(bg=BAR, fg=DIM)
def _click(self, _=None):
if self._cmd:
self._cmd()
class PlayButton(tk.Canvas):
"""Большая зелёная кнопка в стиле Minecraft."""
def __init__(self, master, command, width=300, height=52):
super().__init__(master, width=width, height=height, bg=BG,
highlightthickness=0, cursor="hand2")
self.text = T("ИГРАТЬ")
self._cmd = command
self.h = height
self.state = "normal"
self.bind("<Configure>", lambda e: self._draw())
self.bind("<Enter>", self._enter)
self.bind("<Leave>", self._leave)
self.bind("<Button-1>", self._press)
self.bind("<ButtonRelease-1>", self._release)
def set_state(self, state):
self.state = state
self.configure(cursor="" if state == "disabled" else "hand2")
self._draw()
def _enter(self, _=None):
if self.state == "normal":
self.state = "hover"
self._draw()
def _leave(self, _=None):
if self.state in ("hover", "press"):
self.state = "normal"
self._draw()
def _press(self, _=None):
if self.state == "disabled":
return
self.state = "press"
self._draw()
def _release(self, _=None):
if self.state == "press":
self.state = "hover"
self._draw()
if self._cmd:
self._cmd()
def _draw(self):
w = self.winfo_width()
if w < 40:
return
self.delete("all")
r, depth = 8, 5
pressed = self.state == "press"
if self.state == "disabled":
if THEME == "light":
base, hi, lo, border = "#d5d5d5", "#e2e2e2", "#bcbcbc", "#a6a6a6"
tfg, tshadow = FAINT, "#9f9f9f"
else:
base, hi, lo, border = "#33383f", "#3d434b", "#262a30", "#20242a"
tfg, tshadow = FAINT, "#1a1a1a"
elif self.state in ("hover", "press"):
base, hi, lo, border = GREEN_HI, mix(GREEN_HI, "#ffffff", 0.35), GREEN, GREEN_BD
tfg, tshadow = "#ffffff", "#2c4a1e"
else:
base, hi, lo, border = GREEN, mix(GREEN, "#ffffff", 0.3), GREEN_LO, GREEN_BD
tfg, tshadow = "#ffffff", "#2c4a1e"
y_off = 1 if pressed else 0
# нижняя грань (эффект глубины)
round_rect(self, 2, depth, w - 2, self.h, r, lo, border, 2)
# лицевая грань
round_rect(self, 2, y_off, w - 2, self.h - depth + y_off, r,
base, border, 2)
# блик сверху
self.create_line(14, y_off + 5, w - 14, y_off + 5, fill=hi, width=2)
# пиксельный текст с тенью
scale = 5
tw = pixel_text_width(self.text, scale)
tx = (w - tw) // 2
ty = y_off + (self.h - depth - 7 * scale) // 2
draw_pixel_text(self, tx, ty, self.text, scale, tfg, tshadow, 2, 2)
class VersionPill(tk.Canvas):
"""Пилюля с версией игры под кнопкой."""
def __init__(self, master, command, width=230, height=28):
super().__init__(master, width=width, height=height, bg=BG,
highlightthickness=0, cursor="hand2")
self._text = "Minecraft …"
self._cmd = command
self.bind("<Configure>", lambda e: self._draw())
self.bind("<Enter>", lambda e: (self.configure(bg=HOVER), self._draw()))
self.bind("<Leave>", lambda e: (self.configure(bg=BG), self._draw()))
self.bind("<Button-1>", lambda e: self._cmd and self._cmd())
def set_text(self, text):
self._text = text
self._draw()
def _draw(self):
w = self.winfo_width()
if w < 40:
return
self.delete("all")
round_rect(self, 1, 1, w - 1, 27, 13, PILL, PILL_BD)
self.create_text(w // 2, 14, text=self._text, fill=DIM,
font=("Segoe UI", 9))
class SlimProgress(tk.Canvas):
"""Тонкая полоса прогресса по низу окна."""
def __init__(self, master, height=3):
super().__init__(master, height=height, bg=BAR, highlightthickness=0)
self.pct = 0.0
self.bind("<Configure>", lambda e: self._draw())
def set(self, pct):
self.pct = max(0.0, min(100.0, pct))
self._draw()
def _draw(self):
w = self.winfo_width()
self.delete("all")
if self.pct > 0 and w > 2:
self.create_rectangle(0, 0, max(2, w * self.pct / 100), 99,
fill=GREEN, outline="", tags="fill")
def _dark_titlebar(w):
"""Тёмная полоса заголовка у системного окна (Windows 10 1809+)."""
if os.name != "nt":
return
try:
import ctypes
# 1 — тёмный титлбар, 0 — системный светлый (светлая тема)
val = ctypes.c_int(1 if THEME == "dark" else 0)
for attr in (20, 19): # DWMWA_USE_IMMERSIVE_DARK_MODE
if ctypes.windll.dwmapi.DwmSetWindowAttribute(
w.winfo_id(), attr, ctypes.byref(val),
ctypes.sizeof(val)) == 0:
break
except Exception: # noqa: BLE001
pass
# ============================================================ приложение
class App(tk.Tk):
def __init__(self, select_version=None, protocol_url=None,
select_profile=None):
super().__init__()
self.title("PixelLauncher")
self.overrideredirect(True)
self.geometry(f"{WIN_W}x{WIN_H}")
self.configure(bg=BG)
self.settings = config.load_settings()
set_language(self.settings.get("lang", "ru"))
set_theme(self.settings.get("theme", "dark"))
set_accent(self.settings.get("accent", GREEN))
self.configure(bg=BG)
self.ui_q = queue.Queue()
self.busy = False
self.versions = []
self.ver_meta = {}
self._latest = {}
self._news = None
self._icon_img = None
self._head_img = None
self._head_after = None
self._log_lines = []
self._menu = None
self._close_after_menu = False
self._pending_restore = False
self._saved_geom = None
self._cancel = None # threading.Event отмены загрузки
self._banner_after = None
self._log_txt_widget = None
self._log_view_mode = "session" # "session" или имя файла лога
self._log_orig_lines = [] # строки выбранного файла (для фильтра)
self._find_state = {"idx": -1, "n": 0}
self._force_version = select_version # версия из CLI (ярлык)
self._tray = None
self._update_active = False # идёт проверка/загрузка обновления
self._update_quiet = False # проверка при старте (без лишних окон)
self._base_scaling = float(self.tk.call("tk", "scaling"))
self._game_proc = None # процесс игры (для плагинов)
self._pending_import = None # .mrpack из pixellauncher://
self.pl_q = queue.Queue() # UI-запросы плагинов/протокола
# статистика игры и прокси — рядом с папкой игры
stats_mod.set_path(os.path.join(self._game_dir(), ".launcher"))
http.configure(self.settings.get("proxy"))
self._scene = self._pick_scene()
self._night = self._scene == "night"
self._apply_ui_scale()
self._build()
self._set_icon()
self._enable_dnd()
self._load_settings_to_ui()
if select_profile:
self._apply_profile_at_start(select_profile)
self._center()
self.after(60, self._apply_taskbar_style)
self.after(100, self._poll_ui)
self.after(700, self._start_tray)
self._init_plugins()
self.after(800, self._proto_tick)
if protocol_url:
self.after(1200, lambda: self._start_protocol(protocol_url))
self.after(6000, lambda: self._check_update(True)) # тихая проверка
self.bind("<Map>", self._on_map)
self.protocol("WM_DELETE_WINDOW", self._on_close)
threading.Thread(target=self._load_versions_bg, daemon=True).start()
nick = self.var_nick.get().strip()
if re.fullmatch(r"[A-Za-z0-9_]{3,16}", nick):
threading.Thread(target=self._load_head_bg, args=(nick,),
daemon=True).start()
# ------------------------------------------------------------ каркас
def _build(self):
# --- Строка окна (кастомный заголовок)
bar = tk.Frame(self, bg=BAR, height=44)
bar.pack(fill="x")
bar.pack_propagate(False)
left = tk.Frame(bar, bg=BAR)
left.pack(side="left", padx=(12, 0))
self._logo(left, 22).pack(side="left", padx=(0, 8))
tk.Label(left, text="PixelLauncher", bg=BAR, fg=TEXT,
font=("Segoe UI", 11, "bold")).pack(side="left")
right = tk.Frame(bar, bg=BAR)
right.pack(side="right", padx=(0, 4))
self.lbl_nick = tk.Label(right, text=self.settings.get("username", "Player"),
bg=BAR, fg=TEXT, font=("Segoe UI", 10),
cursor="hand2")
self.lbl_nick.pack(side="left", padx=(0, 6))
self.lbl_nick.bind("<Button-1>", lambda e: self._open_nick_menu())
self.head_canvas = tk.Canvas(right, width=24, height=24, bg=BAR,
highlightthickness=0)
self.head_canvas.pack(side="left", padx=(0, 8))
self._draw_head_placeholder()
Icon_label(right, "☾", lambda: self._set_night(True)).pack(side="left")
Icon_label(right, "☀", lambda: self._set_night(False)).pack(side="left")
Icon_label(right, "⚙", self._open_menu).pack(side="left")
Icon_label(right, "—", self._minimize).pack(side="left")
Icon_label(right, "✕", self._on_close, danger=True).pack(side="left")
tk.Frame(self, bg=BAR_BD, height=1).pack(fill="x")
# перетаскивание окна за пустые места строки
self._make_draggable(left)
bar.bind("<Button-1>", self._drag_start)
bar.bind("<B1-Motion>", self._drag_move)
# --- Основной контент
main = tk.Frame(self, bg=BG)
main.pack(fill="both", expand=True, pady=(26, 0))
self.banner = tk.Canvas(main, width=BANNER_W, height=BANNER_H,
bg=BG, highlightthickness=0)
self.banner.pack(fill="x")
self.banner.bind("<Configure>", self._on_banner_configure)
self.btn_play = PlayButton(main, self._on_play)
self.btn_play.pack(pady=(26, 0))
self.pill = VersionPill(main, self._open_versions)
self.pill.pack(pady=(14, 0))
# --- Лента новостей: последние версии из манифеста Mojang
self.news = tk.Frame(main, bg=BG)
self.news.pack(pady=(16, 0))
tk.Label(self.news, text=T("ПОСЛЕДНИЕ ВЕРСИИ"), bg=BG, fg=FAINT,
font=("Segoe UI", 7, "bold")).pack()
self.news_rows = tk.Frame(self.news, bg=BG)
self.news_rows.pack()
self._fill_news(self._news)
# --- Быстрые настройки прямо на главном экране: частые переключатели
# не требуют ни меню ⚙, ни диалога «Настройки».
self.quick_bar = tk.Frame(main, bg=BG)
self.quick_bar.pack(pady=(10, 0))
self._build_quick_bar(self.quick_bar)
self.lbl_status = tk.Label(main, text=T("Готово к запуску"), bg=BG, fg=DIM,
font=("Segoe UI", 8))
self.lbl_status.pack(pady=(12, 0))
# Кнопка отмены — видна только пока идёт загрузка
self.btn_cancel = tk.Button(main, text="■ " + T("ОТМЕНА"),
command=self._on_cancel, bg=PANEL, fg=ERR,
activebackground=HOVER, activeforeground=ERR,
relief="flat", bd=0, padx=12, pady=2,
cursor="hand2", font=("Segoe UI", 8, "bold"))
self.btn_cancel.pack(pady=(8, 0))
self.btn_cancel.pack_forget()
# --- Тонкий прогресс по низу
self.progress = SlimProgress(self)
self.progress.pack(side="bottom", fill="x")
# --- Ручка изменения размера окна (окно без рамки)
grip = tk.Label(self, text="◢", bg=BAR, fg=FAINT, cursor="sizing",
width=2, font=("Segoe UI", 7))
grip.place(relx=1.0, rely=1.0, anchor="se")
def grip_start(e):
self._grip = (e.x_root, e.y_root, self.winfo_width(),
self.winfo_height())
def grip_move(e):
w = max(WIN_W, self._grip[2] + e.x_root - self._grip[0])
h = max(WIN_H, self._grip[3] + e.y_root - self._grip[1])
self.geometry(f"{w}x{h}")
grip.bind("<Button-1>", grip_start)
grip.bind("<B1-Motion>", grip_move)
def _on_banner_configure(self, _event=None):
"""Отложенная перерисовка баннера при изменении размера окна."""
if self._banner_after is not None:
self.after_cancel(self._banner_after)
self._banner_after = self.after(160, self._render_banner)
def _render_banner(self, event=None):
if event is not None: # <Configure> — перерисуем через паузу
self._on_banner_configure()
return
self._banner_after = None
w = self.banner.winfo_width()
h = self.banner.winfo_height()
if w < 40 or h < 40:
return
cv = self.banner
cv.delete("all")
draw_scene(cv, w, h, scene=self._scene)
# Пиксельный заголовок поверх пейзажа
scale = 6
shadow = {"night": "#0d1420", "nether": "#2a0e0e",
"cave": "#14171a"}.get(self._scene, "#25331f")
draw_pixel_text(cv, 36, 34, "PIXEL", scale, "#ffffff", shadow, 3, 3)
draw_pixel_text(cv, 36, 90, "LAUNCHER", scale, "#ffffff", shadow, 3, 3)
round_mask(cv, w, h, 14, BG)
def _set_night(self, night):
self._set_scene("night" if night else "day")
def _pick_scene(self):
"""Сцена баннера из настроек; «random» — случайная при каждом старте."""
sc = self.settings.get("scene", "day")
if sc == "random":
return random.choice(("day", "night", "nether", "cave"))
return sc if sc in ("day", "night", "nether", "cave") else "day"
def _set_scene(self, scene, save=False):
self._scene = scene if scene in ("day", "night", "nether",
"cave") else "day"
self._night = self._scene == "night"
if save:
self.settings["scene"] = scene
config.save_settings(self.settings)
self._render_banner()
names = {"day": T("Дневной пейзаж"), "night": T("Ночной пейзаж"),
"nether": T("Пейзаж Нижнего мира"),
"cave": T("Пещерный пейзаж")}
self._log(T("Сцена: ") + names.get(self._scene, self._scene), "ok")
# ------------------------------------------------------------ окно
def _make_draggable(self, w):
"""Вешает перетаскивание окна (только для безопасных виджетов)."""
w.bind("<Button-1>", self._drag_start)
w.bind("<B1-Motion>", self._drag_move)
for c in w.winfo_children():
self._make_draggable(c)
def _drag_start(self, e):
self._drag_dx = e.x_root - self.winfo_rootx()
self._drag_dy = e.y_root - self.winfo_rooty()
def _drag_move(self, e):
self.geometry("+%d+%d" % (e.x_root - self._drag_dx,
e.y_root - self._drag_dy))
def _apply_taskbar_style(self):
"""Окно без рамки должно иметь кнопку в панели задач."""
if os.name != "nt":
return
try:
import ctypes
u32 = ctypes.windll.user32
hwnd = u32.GetAncestor(self.winfo_id(), 2) # GA_ROOT
if not hwnd:
return
GWL_EXSTYLE, EX_APP, EX_TOOL = -20, 0x00040000, 0x00000080
style = u32.GetWindowLongW(hwnd, GWL_EXSTYLE)
u32.SetWindowLongW(hwnd, GWL_EXSTYLE,
(style | EX_APP) & ~EX_TOOL)
except Exception: # noqa: BLE001
pass
def _minimize(self):
if os.name == "nt" and self._tray is not None:
self.withdraw() # сворачиваемся в трей (иконка уже там)
return
if os.name != "nt":
self.iconify()
return
try:
self._saved_geom = self.geometry()
self._pending_restore = True
self.overrideredirect(False)
self.iconify()
except tk.TclError:
self._pending_restore = False
def _on_map(self, event):
if event.widget is not self or not self._pending_restore:
return
self._pending_restore = False
def restore():
try:
self.overrideredirect(True)
if self._saved_geom:
self.geometry(self._saved_geom)
self._apply_taskbar_style()
except tk.TclError:
pass
self.after(20, restore)
def _logo(self, master, size):
c = tk.Canvas(master, width=size, height=size, bg=BAR,
highlightthickness=0)
u = max(1, size // 8)
pattern = ["gGgGgGgG", "gGgGgGgG", "dddddddd", "dDdDdddd",
"dddddddd", "dDddddDd", "dddddddd", "dDdddDdd"]
cmap = {"g": "#4f8f3f", "G": "#63a952", "d": "#6b4a2f", "D": "#7d5836"}
for ry, row in enumerate(pattern):
for rx, ch in enumerate(row):
c.create_rectangle(rx * u, ry * u, (rx + 1) * u, (ry + 1) * u,
fill=cmap[ch], outline="")
return c
def _center(self):
self.update_idletasks()
sw, sh = self.winfo_screenwidth(), self.winfo_screenheight()
x = max(0, (sw - WIN_W) // 2)
y = max(0, (sh - WIN_H) // 2 - 20)
self.geometry(f"+{x}+{y}")
def _set_icon(self):
try:
size = 32
img = tk.PhotoImage(width=size, height=size)
u = size // 8
# мини-версия логотипа (assets/make_logo.py, вариант gold):
# золотая P на тёмном слоте, травяной край, скруглённые углы
pattern = [".llllll.", ".pp..pp.", ".pp..pp.", ".pppppp.",
".pp.....", ".pp.....", ".pp.....", ".Gggggg."]
cmap = {".": "#1b1f2b", "l": "#ffd98a", "p": "#ffb454",
"g": "#7cb342", "G": "#5a8f32"}
corners = {(0, 0), (7, 0), (0, 7), (7, 7)}
for ry, row in enumerate(pattern):
for rx, ch in enumerate(row):
if (rx, ry) in corners:
continue
img.put(cmap[ch], to=(rx * u, ry * u, (rx + 1) * u, (ry + 1) * u))
self._icon_img = img
self.iconphoto(True, img)
except tk.TclError:
pass
# ------------------------------------------------------------ голова
def _draw_head_placeholder(self):
c = self.head_canvas
c.delete("all")
u = 3
pattern = ["hhhhhhhh", "hhhhhhhh", "hssssssh", "sswwbbss",
"ssssnnss", "ssmmmmss", "ssssssss", "ssssssss"]
cmap = {"h": "#2f241b", "s": "#b98a68", "w": "#f2f2f2", "b": "#4a6f9c",
"n": "#a97c5c", "m": "#7a5641"}
for ry, row in enumerate(pattern):
for rx, ch in enumerate(row):
c.create_rectangle(rx * u, ry * u, (rx + 1) * u, (ry + 1) * u,
fill=cmap[ch], outline="")
def _load_head_bg(self, nick):
cache_dir = os.path.join(self._game_dir(), ".launcher", "heads")
path = os.path.join(cache_dir, nick + ".gif")
try:
# кэш живёт сутки, дальше перечитываем с mc-heads
fresh = (os.path.isfile(path) and
time.time() - os.path.getmtime(path) < 86400)
if not fresh:
from . import http
data = http.get_bytes(f"https://mc-heads.net/avatar/{nick}/24",
timeout=10)
os.makedirs(cache_dir, exist_ok=True)
with open(path, "wb") as f:
f.write(data)
except Exception:
if not os.path.isfile(path): # сети нет и кэша нет — плейсхолдер
return
if os.path.isfile(path):
self.ui_q.put(("head", nick, path))
def _show_head(self, nick, path):
if self.var_nick.get().strip() != nick:
return
try:
img = tk.PhotoImage(file=path)
self._head_img = img
self.head_canvas.delete("all")
self.head_canvas.create_image(0, 0, anchor="nw", image=img)
except tk.TclError:
self._draw_head_placeholder()
# ------------------------------------------------------------ меню
def _open_nick_menu(self):
"""Быстрое переключение аккаунтов по клику на ник."""
m = tk.Menu(self, tearoff=0, bg=PANEL, fg=TEXT,
activebackground=HOVER, activeforeground=TEXT,
relief="flat", borderwidth=1, font=("Segoe UI", 9))
active = self.settings.get("active_account")
for a in self.settings.get("accounts") or [active]:
mark = "● " if a == active else ""
m.add_command(label=mark + a,
command=lambda x=a: self._switch_account(x))
m.add_separator()
m.add_command(label=T("Аккаунты…"), command=self._open_accounts)
self._menu = m
try:
m.tk_popup(self.lbl_nick.winfo_rootx(),
self.lbl_nick.winfo_rooty() + self.lbl_nick.winfo_height())
except tk.TclError:
pass
finally:
self._menu = None
try:
m.grab_release()
except tk.TclError:
pass
# ------------------------------------------------------------ плагины
def _init_plugins(self):
"""Хуки API плагинов, сканирование папки, событие launcher_ready."""
plugins_mod.bind_ui(
log=lambda m: self.ui_q.put(("log", m, None)),
message=lambda lvl, t, x: self.pl_q.put(("msg", lvl, t, x)),
ask_text=self._plugin_ask,
settings=lambda: self.settings,
game_dir=self._game_dir,
game_running=self._game_running,
)
threading.Thread(target=plugins_mod.scan, daemon=True).start()
self.after(900, lambda: self._emit("launcher_ready"))
def _emit(self, event, **kw):
try:
plugins_mod.emit(event, **kw)
except Exception: # noqa: BLE001
pass
def _game_running(self):
p = self._game_proc
return bool(p) and p.poll() is None
def _plugin_ask(self, title, label, default=""):
"""Синхронный ввод текста для плагина (из любого потока)."""
ev = threading.Event()
holder = []
self.pl_q.put(("ask", title, label, default, ev, holder))
if not ev.wait(timeout=120):
return None
return holder[0] if holder else None
def _start_protocol(self, url):
"""pixellauncher://… → скачивание в фоне, результат в pl_q."""
parsed = protocol_mod.parse(url)
if parsed is None:
messagebox.showerror(
"PixelLauncher",
T("Неизвестная ссылка: ") + str(url), parent=self)
return
kind, target = parsed
def bg():
try:
if kind == "plugin":
plugins_mod.download_plugin(target)
plugins_mod.scan()
self.pl_q.put(("proto", "plugin", target, None))
elif kind == "mrpack":
path = protocol_mod.fetch_mrpack(target)
self.pl_q.put(("proto", "mrpack", path, None))
except Exception as e: # noqa: BLE001
self.pl_q.put(("proto", None, None, str(e)))
threading.Thread(target=bg, daemon=True).start()
def _proto_tick(self):
"""Очередь протокола + UI-запросы плагинов (каждые ~0.7 с)."""
self.after(700, self._proto_tick)
try:
while True:
msg = self.pl_q.get_nowait()
if msg[0] == "msg":
lvl, title, text = msg[1], msg[2], msg[3]
if lvl == "warn":
messagebox.showwarning(title, text, parent=self)
else:
messagebox.showinfo(title, text, parent=self)
elif msg[0] == "ask":
from tkinter import simpledialog
res = simpledialog.askstring(
msg[1], msg[2], parent=self, initialvalue=msg[3])
msg[5].append(res)
msg[4].set()
elif msg[0] == "proto":
self._proto_result(msg[1], msg[2], msg[3])
except queue.Empty:
pass
# ссылки от второго экземпляра лаунчера
for _, url in protocol_mod.queue_files():
self._start_protocol(url)
# отложенный импорт модпака — когда лаунчер свободен
if self._pending_import and not self.busy:
path, self._pending_import = self._pending_import, None
self._import_pack(path=path)
def _proto_result(self, kind, payload, err):
if err:
self._log(T("Протокол: ") + str(err), "err")
messagebox.showerror(
"PixelLauncher",
T("Не удалось выполнить ссылку: ") + str(err),
parent=self)
elif kind == "plugin":
self._log(T("Плагин установлен: ") + str(payload), "ok")
messagebox.showinfo(
"PixelLauncher",
T("Плагин установлен: ") + str(payload) + "\n"
+ T("Он уже загружен."), parent=self)
elif kind == "mrpack":
self._pending_import = payload
def _run_plugin_action(self, fn):
try:
fn()
except Exception as e: # noqa: BLE001
self._log(T("Действие плагина: ошибка: ") + str(e), "err")
def _open_plugins(self):
"""Установленные плагины: список, пересканирование, удаление."""
d = self._dialog(T("Плагины"), width=560)
body = tk.Frame(d, bg=BG)
body.pack(fill="both", expand=True, padx=14, pady=10)
lb = tk.Listbox(body, height=10, bg=INPUT, fg=TEXT,
selectbackground=GREEN, selectforeground="#0d1a0d",
relief="solid", bd=1, highlightthickness=0,
font=("Consolas", 9), exportselection=False)
lb.pack(fill="both", expand=True)
info = tk.Label(body, text="", bg=BG, fg=DIM,
font=("Segoe UI", 8), justify="left", anchor="w")
info.pack(fill="x", pady=(6, 0))
entries = []
def refresh():
lb.delete(0, "end")
entries[:] = []
for p in plugins_mod.loaded():
entries.append({"kind": "ok", "p": p})
ver = (" (%s)" % p.version) if p.version else ""
lb.insert("end", "✓ " + p.file + ver + " — " + p.name)
for fname, err in plugins_mod.failures():
entries.append({"kind": "err", "file": fname, "err": err})
lb.insert("end", "✗ " + fname + " — " + err)
if not entries:
lb.insert("end", T("Плагинов нет. Загрузите из магазина."))
info.configure(text=T("Папка плагинов: ") + plugins_mod.PLUGIN_DIR)
def sel():
s = lb.curselection()
if not s or s[0] >= len(entries):
return None
return entries[s[0]]
def rescan():
done = threading.Event()
def go():
plugins_mod.scan()
done.set()
threading.Thread(target=go, daemon=True).start()
def wait_done():
if not d.winfo_exists():
return
if done.is_set():
refresh()
else:
d.after(150, wait_done)
wait_done()
def remove():
e = sel()
if not e:
return
fname = e["p"].file if e["kind"] == "ok" else e["file"]
if messagebox.askyesno(
T("Плагины"),
T("Удалить плагин {f}?").format(f=fname), parent=d):
plugins_mod.remove_file(fname)
rescan()
btns = tk.Frame(body, bg=BG)
btns.pack(fill="x", pady=(10, 0))
self._btn(btns, T("Пересканировать"), rescan, primary=True).pack(
side="left")
self._btn(btns, T("Удалить"), remove).pack(side="left", padx=(6, 0))
self._btn(btns, T("Открыть папку"),
lambda: self._open_explorer(plugins_mod.PLUGIN_DIR)).pack(
side="left", padx=(6, 0))
self._btn(btns, T("Магазин плагинов…"), self._open_store).pack(
side="left", padx=(6, 0))
self._btn(btns, T("Закрыть"), d.destroy).pack(side="right")
refresh()
self._finish_dialog(d)
def _open_store(self):
"""Магазин плагинов: каталог с сайта и установка в один клик."""
d = self._dialog(T("Магазин плагинов"), width=560)
body = tk.Frame(d, bg=BG)
body.pack(fill="both", expand=True, padx=14, pady=10)
status = tk.Label(body, text=T("Загрузка каталога…"), bg=BG, fg=DIM,
font=("Segoe UI", 8), anchor="w")
status.pack(fill="x")
lb = tk.Listbox(body, height=10, bg=INPUT, fg=TEXT,
selectbackground=GREEN, selectforeground="#0d1a0d",
relief="solid", bd=1, highlightthickness=0,
font=("Segoe UI", 9), exportselection=False)
lb.pack(fill="both", expand=True, pady=(6, 0))
catalog = []
def fill(items):
nonlocal catalog
catalog = items or []
have = set(plugins_mod.plugin_files())
lb.delete(0, "end")
for it in catalog:
mark = "✓ " if str(it.get("file")) in have else ""
lb.insert("end", "%s%s — %s"
% (mark, it.get("title") or it.get("file"),
it.get("desc") or ""))
status.configure(text=T("Плагинов в каталоге: ")
+ str(len(catalog)), fg=DIM)
def load():
status.configure(text=T("Загрузка каталога…"), fg=DIM)
box = {"cat": None, "err": None}
done = threading.Event()
def go():
try:
box["cat"] = plugins_mod.fetch_catalog()
except Exception as e: # noqa: BLE001
box["err"] = str(e)
done.set()
threading.Thread(target=go, daemon=True).start()
def wait():
if not d.winfo_exists():
return
if not done.is_set():
d.after(150, wait)
return
if box["err"]:
status.configure(
text=T("Каталог недоступен: ") + box["err"], fg=ERR)
else:
fill(box["cat"])
wait()
def install():
s = lb.curselection()
if not s or s[0] >= len(catalog):
return
fname = str(catalog[s[0]].get("file") or "")
btn_i.configure(state="disabled")
status.configure(text=T("Установка…"), fg=DIM)
res = {"err": None}
done = threading.Event()
def go():
try:
plugins_mod.download_plugin(fname)
plugins_mod.scan()
except Exception as e: # noqa: BLE001
res["err"] = str(e)
done.set()
threading.Thread(target=go, daemon=True).start()
def wait():
if not d.winfo_exists():
return
if not done.is_set():
d.after(150, wait)
return
btn_i.configure(state="normal")
if res["err"]:
status.configure(text=T("Ошибка: ") + res["err"],
fg=ERR)
else:
status.configure(
text=T("Плагин установлен: ") + fname, fg=OK)
fill(catalog)
wait()
btns = tk.Frame(body, bg=BG)
btns.pack(fill="x", pady=(10, 0))
btn_i = self._btn(btns, T("Установить"), install, primary=True)
btn_i.pack(side="left")
self._btn(btns, T("Обновить список"), load).pack(
side="left", padx=(6, 0))
self._btn(btns, T("Закрыть"), d.destroy).pack(side="right")
load()
self._finish_dialog(d)
def _open_packs(self):
"""Каталог модпаков: список с сайта и установка в один клик."""
d = self._dialog(T("Каталог модпаков"), width=580)
body = tk.Frame(d, bg=BG)
body.pack(fill="both", expand=True, padx=14, pady=10)
status = tk.Label(body, text=T("Загрузка каталога…"), bg=BG, fg=DIM,
font=("Segoe UI", 8), anchor="w")
status.pack(fill="x")
lb = tk.Listbox(body, height=10, bg=INPUT, fg=TEXT,
selectbackground=GREEN, selectforeground="#0d1a0d",
relief="solid", bd=1, highlightthickness=0,
font=("Segoe UI", 9), exportselection=False)
lb.pack(fill="both", expand=True, pady=(6, 0))
catalog = []
dl = {"cancel": None}
def fill(items):
nonlocal catalog
catalog = items or []
lb.delete(0, "end")
for it in catalog:
bits = []
if it.get("mc"):
bits.append("MC " + str(it["mc"]))
if it.get("loader"):
bits.append(str(it["loader"]))
if it.get("size"):
bits.append(str(it["size"]) + " " + T("МБ"))
tail = (" [" + " · ".join(bits) + "]") if bits else ""
lb.insert("end", "%s — %s%s"
% (it.get("title") or "?",
it.get("desc") or "", tail))
status.configure(text=T("Модпаков в каталоге: ")
+ str(len(catalog)), fg=DIM)
def load():
status.configure(text=T("Загрузка каталога…"), fg=DIM)
box = {"cat": None, "err": None}
done = threading.Event()
def go():
try:
box["cat"] = mrpack_mod.fetch_catalog()
except Exception as e: # noqa: BLE001
box["err"] = str(e)
done.set()
threading.Thread(target=go, daemon=True).start()
def wait():
if not d.winfo_exists():
return
if not done.is_set():
d.after(150, wait)
return
if box["err"]:
status.configure(
text=T("Каталог недоступен: ") + box["err"], fg=ERR)
else:
fill(box["cat"])
wait()
def install():
s = lb.curselection()
if not s or s[0] >= len(catalog):
return
if self.busy:
status.configure(
text=T("Дождитесь окончания текущей загрузки"), fg=ERR)
return
entry = catalog[s[0]]
url = mrpack_mod.pack_url(entry)
btn_i.configure(state="disabled")
ev = dl["cancel"] = threading.Event()
box = {"err": None, "pct": -1, "path": None}
done = threading.Event()
def go():
try:
safe = (re.sub(r"[^\w.\-]+", "_",
str(entry.get("title") or "pack"))[:60]
or "pack")
dest = os.path.join(tempfile.gettempdir(), "PixelLauncher",
safe + ".mrpack")
os.makedirs(os.path.dirname(dest), exist_ok=True)
def prog(done_b, total_b):
box["pct"] = (int(done_b * 100 / total_b)
if total_b else -1)
http.download_file(url, dest, progress=prog, cancel=ev)
box["path"] = dest
except http.Cancelled:
box["err"] = T("Загрузка отменена")
except Exception as e: # noqa: BLE001
box["err"] = str(e)
done.set()
threading.Thread(target=go, daemon=True).start()
def wait():
if not done.is_set():
if not d.winfo_exists():
return
if box["pct"] >= 0:
status.configure(
text=T("Скачивание: ") + str(box["pct"]) + "%",
fg=DIM)
d.after(200, wait)
return
if not d.winfo_exists():
return
btn_i.configure(state="normal")
dl["cancel"] = None
if box["err"]:
status.configure(text=T("Ошибка: ") + box["err"], fg=ERR)
else:
path = box["path"]
d.destroy()
self._import_pack(path) # лоадер+файлы: главный прогресс
wait()
def close():
if dl.get("cancel") is not None:
dl["cancel"].set()
d.destroy()
btns = tk.Frame(body, bg=BG)
btns.pack(fill="x", pady=(10, 0))
btn_i = self._btn(btns, T("Установить"), install, primary=True)
btn_i.pack(side="left")
self._btn(btns, T("Обновить список"), load).pack(
side="left", padx=(6, 0))
self._btn(btns, T("Закрыть"), close).pack(side="right")
d.protocol("WM_DELETE_WINDOW", close)
load()
self._finish_dialog(d)
def _open_menu(self):
m = tk.Menu(self, tearoff=0, bg=PANEL, fg=TEXT,
activebackground=HOVER, activeforeground=TEXT,
relief="flat", borderwidth=1, font=("Segoe UI", 9))
m.add_command(label=T("Профиль…"), command=self._open_profile)
m.add_command(label=T("Профили…"), command=self._open_profiles)
m.add_command(label=T("Аккаунты…"), command=self._open_accounts)
m.add_command(label=T("Выбрать версию…"), command=self._open_versions)
m.add_command(label=T("Установить лоадер…"),
command=self._open_fabric)
m.add_command(label=T("Моды…"), command=self._open_mods)
m.add_command(label=T("Импорт модпака…"), command=self._import_pack)
m.add_command(label=T("Каталог модпаков…"), command=self._open_packs)
m.add_command(label=T("Сборки Modrinth…"), command=self._open_modrinth_packs)
m.add_command(label=T("Сборки CurseForge…"), command=self._open_curseforge_packs)
m.add_command(label=T("Плагины…"), command=self._open_plugins)
m.add_command(label=T("Магазин плагинов…"), command=self._open_store)
m.add_command(label=T("Скин-превью…"), command=self._open_skin)
m.add_separator()
m.add_command(label=T("Резервная копия миров"), command=self._on_backup)
m.add_command(label=T("Миры и копии…"), command=self._open_worlds)
m.add_command(label=T("Скачать эту версию"), command=self._on_download)
m.add_command(label=T("Проверить файлы игры…"), command=self._on_verify)
m.add_command(label=T("Очистка версий…"), command=self._open_cleanup)
m.add_command(label=T("История и статистика…"),
command=self._open_history)
scene_menu = tk.Menu(m, tearoff=0, bg=PANEL, fg=TEXT,
activebackground=HOVER, activeforeground=TEXT,
relief="flat", borderwidth=1,
font=("Segoe UI", 9))
for name, label in (("day", T("День")), ("night", T("Ночь")),
("nether", T("Незер")), ("cave", T("Пещера")),
("random", T("Случайно"))):
scene_menu.add_command(
label=("● " if self._scene == name else "") + label,
command=lambda n=name: self._set_scene(n, save=True))
m.add_cascade(label=T("Сцена баннера"), menu=scene_menu)
m.add_command(label=T("Создать ярлык версии…"),
command=self._make_shortcut)
m.add_separator()
m.add_command(label=T("Открыть папку игры"),
command=lambda: self._open_explorer(self._game_dir()))
m.add_command(label=T("Открыть папку миров"),
command=lambda: self._open_explorer(
os.path.join(self._game_dir(), "saves")))
m.add_command(label=T("Открыть скриншоты"),
command=lambda: self._open_explorer(
os.path.join(self._game_dir(), "screenshots")))
m.add_command(label=T("Галерея скриншотов…"),
command=self._open_gallery)
m.add_separator()
m.add_command(label=T("Журнал…"), command=self._open_log)
m.add_cascade(label=T("Быстрые настройки"),
menu=self._quick_settings(m))
m.add_command(label=T("Настройки…"), command=self._open_options)
m.add_command(label=T("Экспорт настроек…"),
command=self._export_settings)
m.add_command(label=T("Импорт настроек…"),
command=self._import_settings)
m.add_command(label=T("Проверить обновление лаунчера…"),
command=self._check_update)
m.add_command(label=T("О программе…"), command=self._open_about)
acts = plugins_mod.actions()
if acts:
m.add_separator()
for _label, _fn, _pname in acts:
m.add_command(
label=_label,
command=lambda f=_fn: self._run_plugin_action(f))
self._menu = m
try:
m.tk_popup(self.winfo_rootx() + self.winfo_width() - 170,
self.winfo_rooty() + 44)
except tk.TclError:
pass
finally:
self._menu = None
try:
m.grab_release()
except tk.TclError:
pass
if self._close_after_menu:
self._close_after_menu = False
self.after(1, self.destroy)
def _close_menu_if_open(self):
m = self._menu
if m is None:
return False
self._menu = None
try:
m.unpost()
except tk.TclError:
pass
try:
m.grab_release()
except tk.TclError:
pass
return True
# ------------------------------------------------------------ диалоги
def _dialog(self, title, width=420):
d = tk.Toplevel(self)
d.title(title)
d.configure(bg=BG)
d.transient(self)
d.grab_set()
d.resizable(False, False)
d._pl_width = width
self.after(10, lambda: _dark_titlebar(d))
return d
def _finish_dialog(self, d):
d.update_idletasks()
width = getattr(d, "_pl_width", 420)
h = d.winfo_reqheight()
d.geometry(f"{width}x{h}")
d.update_idletasks()
x = self.winfo_rootx() + max(0, (self.winfo_width() - width) // 2)
y = self.winfo_rooty() + max(0, (self.winfo_height() - h) // 2)
d.geometry(f"+{x}+{y}")
def _btn(self, master, text, command, primary=False):
b = tk.Button(master, text=text, command=command,
bg=GREEN if primary else PANEL,
fg="#ffffff" if primary else TEXT,
activebackground=GREEN_HI if primary else HOVER,
activeforeground="#ffffff",
relief="flat", bd=0, padx=14, pady=4, cursor="hand2",
font=("Segoe UI", 9, "bold" if primary else "normal"))
return b
def _field(self, parent, label, var, width=32, browse=None):
row = tk.Frame(parent, bg=BG)
row.pack(fill="x", pady=3)
tk.Label(row, text=label, bg=BG, fg=DIM, width=14, anchor="w",
font=("Segoe UI", 9)).pack(side="left")
e = tk.Entry(row, textvariable=var, width=width, font=("Segoe UI", 9),
bg=INPUT, fg=TEXT, insertbackground=TEXT, relief="solid",
bd=1, highlightbackground=INPUT_BD, highlightthickness=1)
e.pack(side="left", ipady=2)
e.bind("<FocusIn>", lambda ev: e.configure(highlightbackground=GREEN))
e.bind("<FocusOut>", lambda ev: e.configure(highlightbackground=INPUT_BD))
if browse:
self._btn(row, "…", browse).pack(side="left", padx=(4, 0))
return e
def _hint(self, parent, text, indent=108):
row = tk.Frame(parent, bg=BG)
row.pack(fill="x", pady=(1, 3))
tk.Frame(row, width=indent, bg=BG).pack(side="left")
tk.Label(row, text=text, bg=BG, fg=FAINT, anchor="w",
font=("Segoe UI", 8)).pack(side="left", fill="x")
def _open_profile(self):
d = self._dialog(T("Профиль игрока"))
body = tk.Frame(d, bg=BG)
body.pack(fill="both", expand=True, padx=14, pady=10)
var_nick = tk.StringVar(value=self.var_nick.get())
self._field(body, T("Ник:"), var_nick, 20)
ram_frame = tk.Frame(body, bg=BG)
ram_frame.pack(fill="x", pady=3)
tk.Label(ram_frame, text=T("ОЗУ (МБ):"), bg=BG, fg=DIM, width=14,
anchor="w", font=("Segoe UI", 9)).pack(side="left")
var_ram = tk.IntVar(value=self.settings.get("ram", 2048))
var_auto = tk.BooleanVar(value=bool(self.settings.get("auto_ram")))
scale = tk.Scale(ram_frame, from_=1024, to=self._ram_max(),
resolution=512, orient="horizontal", variable=var_ram,
bg=BG, fg=TEXT, troughcolor=INPUT, highlightthickness=0,
length=200, font=("Segoe UI", 8))
scale.pack(side="left")
def toggle_auto():
scale.configure(state="disabled" if var_auto.get() else "normal")
tk.Checkbutton(ram_frame, text=T("Авто (по памяти системы)"),
variable=var_auto, command=toggle_auto, bg=BG, fg=DIM,
selectcolor=INPUT, activebackground=BG, activeforeground=TEXT,
highlightthickness=0, font=("Segoe UI", 8)).pack(side="left",
padx=(8, 0))
if var_auto.get():
toggle_auto()
var_java = tk.StringVar(value=self.settings.get("java_path", ""))
self._field(body, T("Java:"), var_java, 30,
browse=lambda: self._pick_java_file(var_java))
self._hint(body, T("Пусто = подбор Java автоматически."))
var_jvm = tk.StringVar(value=self.settings.get("jvm_args", ""))
self._field(body, T("JVM-аргументы:"), var_jvm, 30)
# --- Пресет JVM (заполняет поле аргументов готовым набором)
presets = {
"": (T("(нет)"), ""),
"perf": (T("Производительность (1.16+)"),
"-XX:+UseG1GC -XX:+ParallelRefProcEnabled "
"-XX:MaxGCPauseMillis=200 -XX:+UnlockExperimentalVMOptions"
" -XX:+DisableExplicitGC -XX:+AlwaysPreTouch"),
"old": (T("Старые версии (Java 8)"),
"-XX:+UseConcMarkSweepGC -XX:+CMSClassUnloadingEnabled "
"-XX:PermSize=256m -XX:MaxPermSize=512m"),
}
pre_row = tk.Frame(body, bg=BG)
pre_row.pack(fill="x", pady=3)
tk.Label(pre_row, text=T("Пресет JVM:"), bg=BG, fg=DIM, width=14,
anchor="w", font=("Segoe UI", 9)).pack(side="left")
var_preset = tk.StringVar(value=presets.get(
self.settings.get("jvm_preset", ""), presets[""])[0])
cb_preset = ttk.Combobox(pre_row, textvariable=var_preset, width=28,
state="readonly",
values=[v[0] for v in presets.values()])
cb_preset.pack(side="left")
def apply_preset(_e=None):
for key, (label, args) in presets.items():
if label == var_preset.get():
var_jvm.set(args)
var_preset_key[0] = key
break
var_preset_key = [self.settings.get("jvm_preset", "")
if self.settings.get("jvm_preset", "") in presets
else ""]
cb_preset.bind("<<ComboboxSelected>>", apply_preset)
# --- Окно игры: разрешение и полный экран
win_row = tk.Frame(body, bg=BG)
win_row.pack(fill="x", pady=3)
tk.Label(win_row, text=T("Окно игры:"), bg=BG, fg=DIM, width=14,
anchor="w", font=("Segoe UI", 9)).pack(side="left")
var_ww = tk.StringVar(value=str(self.settings.get("win_w") or ""))
var_wh = tk.StringVar(value=str(self.settings.get("win_h") or ""))
tk.Label(win_row, text=T("Ширина:"), bg=BG, fg=FAINT,
font=("Segoe UI", 8)).pack(side="left")
tk.Entry(win_row, textvariable=var_ww, width=6, font=("Segoe UI", 9),
bg=INPUT, fg=TEXT, insertbackground=TEXT, relief="solid",
bd=1, highlightthickness=0).pack(side="left", padx=(3, 8))
tk.Label(win_row, text=T("Высота:"), bg=BG, fg=FAINT,
font=("Segoe UI", 8)).pack(side="left")
tk.Entry(win_row, textvariable=var_wh, width=6, font=("Segoe UI", 9),
bg=INPUT, fg=TEXT, insertbackground=TEXT, relief="solid",
bd=1, highlightthickness=0).pack(side="left", padx=(3, 8))
var_fs = tk.BooleanVar(value=bool(self.settings.get("fullscreen")))
tk.Checkbutton(win_row, text=T("Полный экран"), variable=var_fs,
bg=BG, fg=DIM, selectcolor=INPUT, activebackground=BG,
activeforeground=TEXT, highlightthickness=0,
font=("Segoe UI", 8)).pack(side="left")
self._hint(body, T("0 или пусто = как в игре."))
btns = tk.Frame(body, bg=BG)
btns.pack(fill="x", pady=(12, 0))
def save():
nick = var_nick.get().strip()
if not re.fullmatch(r"[A-Za-z0-9_]{3,16}", nick):
messagebox.showwarning("PixelLauncher",
T("Ник: 3-16 символов, латиница, цифры и _"),
parent=d)
return
try:
ww = int(var_ww.get().strip() or 0)
wh = int(var_wh.get().strip() or 0)
if ww < 0 or wh < 0 or ww > 10000 or wh > 10000:
raise ValueError
except ValueError:
messagebox.showwarning("PixelLauncher",
T("0 или пусто = как в игре."),
parent=d)
return
self._rename_active_account(nick)
self.settings.update({
"username": nick, "ram": int(var_ram.get()),
"auto_ram": bool(var_auto.get()),
"java_path": var_java.get().strip(),
"jvm_args": var_jvm.get().strip(),
"jvm_preset": var_preset_key[0],
"win_w": ww, "win_h": wh,
"fullscreen": bool(var_fs.get()),
})
config.save_settings(self.settings)
self.var_nick.set(nick)
self.lbl_nick.configure(text=nick)
self._log(T("Профиль сохранён: ") + nick, "ok")
threading.Thread(target=self._load_head_bg, args=(nick,),
daemon=True).start()
d.destroy()
self._btn(btns, T("Скин-превью…"), self._open_skin).pack(side="left")
self._btn(btns, T("Сохранить"), save, primary=True).pack(side="right")
self._btn(btns, T("Отмена"), d.destroy).pack(side="right", padx=(0, 6))
d.bind("<Return>", lambda e: save())
self._finish_dialog(d)
def _open_profiles(self):
"""Список профилей запуска: применить, создать, изменить, удалить."""
d = self._dialog(T("Профили"), width=660)
body = tk.Frame(d, bg=BG)
body.pack(fill="both", expand=True, padx=14, pady=10)
lb = tk.Listbox(body, height=10, bg=INPUT, fg=TEXT,
selectbackground=GREEN, selectforeground="#0d1a0d",
relief="solid", bd=1, highlightthickness=0,
font=("Segoe UI", 9), exportselection=False)
lb.pack(fill="both", expand=True)
lbl_hint = tk.Label(body, text="", bg=BG, fg=FAINT, anchor="w",
font=("Segoe UI", 8))
lbl_hint.pack(fill="x", pady=(4, 0))
def profiles():
return list(self.settings.get("profiles") or [])
def refresh():
lb.delete(0, "end")
ps = profiles()
active = self.settings.get("active_profile") or ""
for p in ps:
mods_n = ""
if isinstance(p.get("mods"), list):
mods_n = f" · {T('Моды')}: {len(p['mods'])}"
lb.insert("end", ("● " if p.get("name") == active else " ")
+ str(p.get("name", "?")) + " — "
+ str(p.get("username", "")) + " · "
+ str(p.get("version", "")) + mods_n)
lbl_hint.configure(text=T("Список профилей пуст") if not ps else "")
if ps:
lb.selection_set(0)
def after_edit():
refresh()
try:
d.grab_set()
except tk.TclError:
pass
def selected():
sel = lb.curselection()
ps = profiles()
if not sel or sel[0] >= len(ps):
return None
return ps[sel[0]]
def apply():
p = selected()
if not p:
messagebox.showinfo("PixelLauncher", T("Выберите профиль"),
parent=d)
return
self.apply_profile(p)
refresh()
def make_shortcut():
p = selected()
if not p:
messagebox.showinfo("PixelLauncher", T("Выберите профиль"),
parent=d)
return
self._make_shortcut(p)
refresh()
def clear_profile():
self.settings["active_profile"] = ""
config.save_settings(self.settings)
self._update_pill()
self._log(T("Без профиля"), None)
refresh()
def delete_profile():
p = selected()
if not p:
return
if not messagebox.askyesno(
"PixelLauncher",
T("Удалить профиль {p}?").format(p=p.get("name", "")),
parent=d):
return
self.settings["profiles"] = [x for x in profiles() if x is not p]
if self.settings.get("active_profile") == p.get("name"):
self.settings["active_profile"] = ""
self._update_pill()
config.save_settings(self.settings)
self._log(T("Профиль удалён: ") + str(p.get("name", "")), "ok")
refresh()
lb.bind("<Double-Button-1>", lambda e: apply())
btns = tk.Frame(body, bg=BG)
btns.pack(fill="x", pady=(10, 0))
self._btn(btns, T("Применить"), apply, primary=True).pack(side="right")
self._btn(btns, T("Закрыть"), d.destroy).pack(side="right",
padx=(0, 6))
self._btn(btns, T("Без профиля"), clear_profile).pack(side="left")
self._btn(btns, T("Удалить"), delete_profile).pack(side="left",
padx=(6, 0))
self._btn(btns, T("Изменить"),
lambda: selected() and self._profile_editor(
d, selected(), after_edit)).pack(side="left", padx=(6, 0))
self._btn(btns, T("Новый профиль"),
lambda: self._profile_editor(d, None, after_edit)).pack(
side="left", padx=(6, 0))
self._btn(btns, T("Ярлык"), make_shortcut).pack(side="left",
padx=(6, 0))
refresh()
self._finish_dialog(d)
return d
def _profile_editor(self, parent, p, on_done):
"""Форма создания/изменения профиля запуска. p=None — новый."""
e = self._dialog(T("Профиль"), width=430)
try:
e.transient(parent)
except tk.TclError:
pass
body = tk.Frame(e, bg=BG)
body.pack(fill="both", expand=True, padx=14, pady=10)
cur = p or {}
var_name = tk.StringVar(value=str(cur.get("name", "")))
self._field(body, T("Имя:"), var_name, 22)
var_nick = tk.StringVar(
value=str(cur.get("username") or self.var_nick.get()))
self._field(body, T("Ник:"), var_nick, 22)
var_ver = tk.StringVar(
value=str(cur.get("version") or self.var_ver.get()))
self._field(body, T("Версия:"), var_ver, 22)
var_ram = tk.StringVar(value=str(
cur.get("ram") or self.settings.get("ram", 2048)))
self._field(body, T("ОЗУ (МБ):"), var_ram, 8)
var_fs = tk.BooleanVar(value=bool(
cur.get("fullscreen", self.settings.get("fullscreen"))))
tk.Checkbutton(body, text=T("Полный экран"), variable=var_fs, bg=BG,
fg=DIM, selectcolor=INPUT, activebackground=BG,
activeforeground=TEXT, highlightthickness=0,
font=("Segoe UI", 9)).pack(anchor="w", pady=3)
var_mods = tk.BooleanVar(value=isinstance(cur.get("mods"), list))
tk.Checkbutton(body, text=T("Запомнить включённые моды"),
variable=var_mods, bg=BG, fg=DIM, selectcolor=INPUT,
activebackground=BG, activeforeground=TEXT,
highlightthickness=0,
font=("Segoe UI", 9)).pack(anchor="w", pady=3)
self._hint(body, T("Профиль хранит ник, версию, ОЗУ и по желанию "
"набор включённых модов."), indent=0)
def save():
name = var_name.get().strip()
nick = var_nick.get().strip()
ver = var_ver.get().strip()
if not name:
messagebox.showwarning("PixelLauncher", T("Имя:"), parent=e)
return
if not re.fullmatch(r"[A-Za-z0-9_]{3,16}", nick):
messagebox.showwarning(
"PixelLauncher",
T("Ник: 3-16 символов, латиница, цифры и _"), parent=e)
return
if not ver:
messagebox.showwarning("PixelLauncher", T("Версия:"),
parent=e)
return
try:
ram = int(var_ram.get().strip() or 2048)
if ram < 256 or ram > 65536:
raise ValueError
except ValueError:
messagebox.showwarning("PixelLauncher", T("ОЗУ (МБ):"),
parent=e)
return
mods = None
if var_mods.get():
mods = [m["file"] for m in mods_mod.list_mods(self._game_dir())
if m["enabled"]]
prof = {"name": name, "username": nick, "version": ver,
"ram": ram, "fullscreen": bool(var_fs.get()), "mods": mods,
"win_w": int(self.settings.get("win_w") or 0),
"win_h": int(self.settings.get("win_h") or 0),
"java_path": str(self.settings.get("java_path") or ""),
"jvm_args": str(self.settings.get("jvm_args") or ""),
"jvm_preset": str(self.settings.get("jvm_preset") or ""),
"auto_ram": bool(self.settings.get("auto_ram"))}
ps = [x for x in (self.settings.get("profiles") or [])
if x is not p]
ps.append(prof)
ps.sort(key=lambda x: str(x.get("name", "")).lower())
self.settings["profiles"] = ps
config.save_settings(self.settings)
e.destroy()
on_done()
self._btn(body, T("Сохранить"), save, primary=True).pack(anchor="e",
pady=(10, 0))
e.bind("<Return>", lambda ev: save())
self._finish_dialog(e)
return e
def apply_profile(self, p):
"""Применяет профиль запуска: ник, версию, ОЗУ, окно, набор модов."""
old_prof = str(self.settings.get("active_profile") or "")
old_acc = self.settings.get("active_account")
nick = str(p.get("username") or "").strip()
if nick:
accs = list(self.settings.get("accounts") or [])
if nick in accs:
self.settings["active_account"] = nick
else:
self._rename_active_account(nick)
self.settings["username"] = nick
self.var_nick.set(nick)
self.lbl_nick.configure(text=nick)
if nick != old_acc:
self._emit("account_switched", username=nick)
ver = str(p.get("version") or "").strip()
if ver:
self.var_ver.set(ver)
for k in ("ram", "auto_ram", "fullscreen", "win_w", "win_h",
"java_path", "jvm_args", "jvm_preset"):
if k in p:
self.settings[k] = p[k]
self.settings["version"] = self.var_ver.get().strip()
mods = p.get("mods")
if isinstance(mods, list):
def base_of(fn):
low = str(fn).lower()
if low.endswith(".disabled"):
low = low[:-len(".disabled")]
if low.endswith(".jar"):
low = low[:-4]
return low
want = {base_of(x) for x in mods}
for m in mods_mod.list_mods(self._game_dir()):
on = base_of(m["file"]) in want
if m["enabled"] != on:
mods_mod.set_enabled(self._game_dir(), m["file"], on)
new_prof = str(p.get("name") or "")
self.settings["active_profile"] = new_prof
config.save_settings(self.settings)
if new_prof and new_prof != old_prof:
self._emit("profile_switched", profile_name=new_prof)
self._update_pill()
self._log(T("Профиль применён: ") + self.settings["active_profile"],
"ok")
def _apply_profile_at_start(self, name):
"""Профиль из CLI (ярлык рабочего стола): применяем, если найден."""
for p in self.settings.get("profiles") or []:
if str(p.get("name") or "") == str(name):
self.apply_profile(p)
ver = str(p.get("version") or "").strip()
if ver:
# версию выбираем после загрузки списка (см. _load_versions)
self._force_version = ver
return
self._log(T("Профиль не найден: ") + str(name), "warn")
def _open_worlds(self):
"""Список миров: копия по кнопке, удаление копий и миров."""
d = self._dialog(T("Миры и копии"), width=620)
body = tk.Frame(d, bg=BG)
body.pack(fill="both", expand=True, padx=14, pady=10)
cols = tk.Frame(body, bg=BG)
cols.pack(fill="both", expand=True)
left = tk.Frame(cols, bg=BG)
left.pack(side="left", fill="both", expand=True)
right = tk.Frame(cols, bg=BG)
right.pack(side="left", fill="both", expand=True, padx=(10, 0))
tk.Label(left, text=T("Миры"), bg=BG, fg=TEXT,
font=("Segoe UI", 9, "bold")).pack(anchor="w")
lb_w = tk.Listbox(left, height=10, bg=INPUT, fg=TEXT,
selectbackground=GREEN, selectforeground="#0d1a0d",
relief="solid", bd=1, highlightthickness=0,
font=("Consolas", 9), exportselection=False)
lb_w.pack(fill="both", expand=True, pady=(4, 0))
tk.Label(right, text=T("Копии"), bg=BG, fg=TEXT,
font=("Segoe UI", 9, "bold")).pack(anchor="w")
lb_b = tk.Listbox(right, height=10, bg=INPUT, fg=TEXT,
selectbackground=GREEN, selectforeground="#0d1a0d",
relief="solid", bd=1, highlightthickness=0,
font=("Consolas", 9), exportselection=False)
lb_b.pack(fill="both", expand=True, pady=(4, 0))
lq = queue.Queue()
def poll():
if not d.winfo_exists():
return
try:
while True:
msg = lq.get_nowait()
if msg[0] == "log":
self._log(msg[1], msg[2] if len(msg) > 2 else None)
elif msg[0] == "refresh":
refresh_all()
except queue.Empty:
pass
d.after(120, poll)
d.after(120, poll)
worlds, backups = [], []
def sel_world():
sel = lb_w.curselection()
if not sel or sel[0] >= len(worlds):
return None
return worlds[sel[0]]
def sel_backup():
sel = lb_b.curselection()
if not sel or sel[0] >= len(backups):
return None
return backups[sel[0]]
def refresh_worlds():
lb_w.delete(0, "end")
worlds[:] = worlds_mod.list_worlds(self._game_dir())
for w in worlds:
lb_w.insert("end", f"{w['name']} "
f"({mods_mod.fmt_size(w['size'])}, "
f"{stats_mod.fmt_time(w['mtime'])})")
if worlds:
lb_w.selection_set(0)
def refresh_backups():
lb_b.delete(0, "end")
backups[:] = []
w = sel_world()
if w:
backups[:] = worlds_mod.list_backups(self._game_dir(), w["name"])
for b in backups:
lb_b.insert("end", f"{b['name']} "
f"({mods_mod.fmt_size(b['size'])})")
if not w:
lb_b.insert("end", T("Выберите мир"))
def refresh_all():
refresh_worlds()
refresh_backups()
refresh_all()
lb_w.bind("<<ListboxSelect>>", lambda e: refresh_backups())
def make_backup():
w = sel_world()
if not w:
messagebox.showinfo("PixelLauncher", T("Выберите мир"),
parent=d)
return
def bg():
try:
cap = int(self.settings.get("world_backups", 5))
path = worlds_mod.backup_world(
self._game_dir(), w["name"], cap,
log=lambda m: lq.put(("log", m)))
lq.put(("log", T("Копия мира {w} создана: ").format(
w=w["name"]) + os.path.basename(path), "ok"))
except Exception as err: # noqa: BLE001
lq.put(("log", T("Ошибка: ") + str(err), "err"))
lq.put(("refresh",))
threading.Thread(target=bg, daemon=True).start()
def delete_backup():
b = sel_backup()
w = sel_world()
if not b or not w:
return
if not messagebox.askyesno(
"PixelLauncher",
T("Удалить копию {b}?").format(b=b["name"]),
parent=d):
return
if worlds_mod.remove_backup(self._game_dir(), w["name"],
b["name"]):
self._log(T("Копия удалена: ") + b["name"], "ok")
refresh_backups()
def open_folder():
w = sel_world()
if not w:
return
path = worlds_mod.backups_dir(self._game_dir(), w["name"])
os.makedirs(path, exist_ok=True)
self._open_explorer(path)
def delete_world():
w = sel_world()
if not w:
return
if not messagebox.askyesno(
"PixelLauncher",
T("Удалить мир {w}? Это нельзя отменить.").format(
w=w["name"]), parent=d):
return
if worlds_mod.remove_world(self._game_dir(), w["name"]):
self._log(T("Мир удалён: ") + w["name"], "ok")
refresh_all()
btns = tk.Frame(body, bg=BG)
btns.pack(fill="x", pady=(10, 0))
self._btn(btns, T("Закрыть"), d.destroy).pack(side="right")
self._btn(btns, T("Открыть папку копий"), open_folder).pack(
side="right", padx=(0, 6))
self._btn(btns, T("Удалить мир"), delete_world).pack(side="left")
self._btn(btns, T("Удалить копию"), delete_backup).pack(
side="left", padx=(6, 0))
self._btn(btns, T("Создать копию"), make_backup, primary=True).pack(
side="left", padx=(6, 0))
self._finish_dialog(d)
return d
def _rename_active_account(self, nick):
"""Переименовывает активный аккаунт в списке (ник в профиле = аккаунт)."""
accs = list(self.settings.get("accounts") or [])
old = self.settings.get("active_account")
if old in accs:
accs[accs.index(old)] = nick
elif nick not in accs:
accs.append(nick)
seen, out = set(), []
for a in accs:
if a not in seen:
seen.add(a)
out.append(a)
self.settings["accounts"] = out
self.settings["active_account"] = nick
def _open_versions(self):
d = self._dialog(T("Выбор версии"), width=470)
body = tk.Frame(d, bg=BG)
body.pack(fill="both", expand=True, padx=14, pady=10)
var_q = tk.StringVar()
e = tk.Entry(body, textvariable=var_q, font=("Segoe UI", 9),
bg=INPUT, fg=TEXT, insertbackground=TEXT, relief="solid",
bd=1, highlightbackground=INPUT_BD, highlightthickness=1)
e.pack(fill="x", ipady=2)
e.focus_set()
lb = tk.Listbox(body, height=16, bg=INPUT, fg=TEXT, selectbackground=GREEN,
selectforeground="#0d1a0d", relief="solid", bd=1,
highlightthickness=0, font=("Consolas", 9),
exportselection=False)
lb.pack(fill="both", expand=True, pady=(8, 0))
tk.Label(body, text=T("★ — закрепить версию, двойной клик — выбрать"),
bg=BG, fg=FAINT, font=("Segoe UI", 8)).pack(anchor="w",
pady=(4, 0))
counts = {} # сколько раз запускалась версия — частые сверху
try:
for r in stats_mod.totals().get("per_version") or []:
counts[r["ver"]] = int(r.get("count") or 0)
except Exception: # noqa: BLE001
pass
var_only_fav = tk.BooleanVar(value=False)
rows = []
def refresh(*_args):
# trace_add вызывает колбэк с 3 аргументами (имя, индекс, оп)
q = var_q.get().strip().lower()
favs = set(self.settings.get("fav_versions") or [])
items = []
for idx, v in enumerate(self.versions):
if q and q not in v.lower():
continue
base = v.split(" ")[0]
vtype, date = self.ver_meta.get(base, ("", ""))
mark = {"release": "", "snapshot": T("[снапшот]"),
"local": T("[локальная]")}.get(vtype, "")
star = "★ " if base in favs else " "
items.append([0 if base in favs else 1,
-counts.get(base, 0), idx,
v, f"{star}{v:<26} {date} {mark}"])
if var_only_fav.get():
items = [it for it in items if it[0] == 0]
items.sort(key=lambda it: (it[0], it[1], it[2]))
lb.delete(0, "end")
rows.clear()
for it in items:
lb.insert("end", it[4])
rows.append(it[3])
if rows:
lb.selection_set(0)
var_q.trace_add("write", refresh)
var_only_fav.trace_add("write", refresh)
refresh()
def selected_base():
sel = lb.curselection()
if not sel or sel[0] >= len(rows):
return None
return rows[sel[0]].split(" ")[0]
def toggle_fav():
base = selected_base()
if not base:
return
favs = list(self.settings.get("fav_versions") or [])
if base in favs:
favs.remove(base)
else:
favs.append(base)
self.settings["fav_versions"] = favs
config.save_settings(self.settings)
refresh()
def choose(_=None):
sel = lb.curselection()
if not sel or sel[0] >= len(rows):
return
self._pick_version(rows[sel[0]])
d.destroy()
lb.bind("<Double-Button-1>", choose)
btns = tk.Frame(body, bg=BG)
btns.pack(fill="x", pady=(10, 0))
self._btn(btns, T("Выбрать"), choose, primary=True).pack(side="right")
self._btn(btns, T("Обновить список"),
lambda: (self._reload_versions(), d.destroy())).pack(side="right",
padx=(0, 6))
self._btn(btns, T("★ Закрепить/убрать"), toggle_fav).pack(side="left")
tk.Checkbutton(btns, text=T("Только избранные"), variable=var_only_fav,
bg=BG, fg=DIM, selectcolor=INPUT, activebackground=BG,
activeforeground=TEXT, highlightthickness=0,
font=("Segoe UI", 9)).pack(side="left", padx=(8, 0))
d.bind("<Return>", lambda e: choose())
self._finish_dialog(d)
return d
def _build_quick_bar(self, parent):
"""Кликабельные чипы быстрых настроек на главном экране.
Дублируют подменю ⚙ → «Быстрые настройки»: язык, масштаб,
автозагрузка, трей и авто-бэкап меняются одним кликом.
"""
def chip(text, command, active=False):
bg = GREEN_BD if active else PILL
fg = TEXT if active else DIM
lbl = tk.Label(parent, text=text, bg=bg, fg=fg,
font=("Segoe UI", 8), padx=9, pady=3,
cursor="hand2")
lbl.pack(side="left", padx=3)
lbl.bind("<Button-1>", lambda e: command())
lbl.bind("<Enter>", lambda e: lbl.configure(bg=HOVER, fg=TEXT))
lbl.bind("<Leave>", lambda e: lbl.configure(bg=bg, fg=fg))
return lbl
s = self.settings
on, off = T("вкл"), T("выкл")
cur_lang = s.get("lang", "ru")
ram_txt = (T("авто") if s.get("auto_ram")
else self._fmt_ram(s.get("ram", 2048)))
chip(T("ОЗУ") + ": " + ram_txt, self._cycle_ram,
active=bool(s.get("auto_ram")))
self.quick_ver_chip = chip(self._ver_chip_text(),
self._open_versions)
chip("RU" if cur_lang == "ru" else "EN",
lambda: self._quick_lang("en" if cur_lang == "ru" else "ru"))
chip("%d%%" % int(round(float(s.get("ui_scale", 1.0)) * 100)),
self._cycle_scale)
chip(T("Автозагрузка") + ": " + (on if s.get("autostart") else off),
self._toggle_autostart, active=bool(s.get("autostart")))
chip(T("Трей") + ": " + (on if s.get("minimize_to_tray_on_launch")
else off),
self._toggle_tray,
active=bool(s.get("minimize_to_tray_on_launch")))
chip(T("Бэкап") + ": " + (on if s.get("auto_backup") else off),
self._toggle_backup, active=bool(s.get("auto_backup")))
@staticmethod
def _fmt_ram(mb):
try:
gb = float(mb) / 1024
except (TypeError, ValueError):
gb = 2.0
return ("%.1f" % gb).rstrip("0").rstrip(".") + " " + T("ГБ")
def _ver_chip_text(self):
vid = self._selected_version_id()
if len(vid) > 14:
vid = vid[:13] + "…"
return T("Версия") + ": " + vid
def _cycle_ram(self):
steps = [None, 2048, 4096, 6144, 8192]
if self.settings.get("auto_ram"):
cur = None
else:
try:
cur = int(self.settings.get("ram", 2048))
except (TypeError, ValueError):
cur = 2048
try:
nxt = steps[(steps.index(cur) + 1) % len(steps)]
except ValueError:
nxt = steps[0]
if nxt is None:
self.settings["auto_ram"] = True
else:
self.settings["auto_ram"] = False
self.settings["ram"] = nxt
config.save_settings(self.settings)
self.after_idle(self._rebuild_ui)
def _cycle_scale(self):
"""Масштаб по кругу: 100 → 125 → 150 → 100 %."""
steps = (100, 125, 150)
cur = int(round(float(self.settings.get("ui_scale", 1.0)) * 100))
try:
nxt = steps[(steps.index(cur) + 1) % len(steps)]
except ValueError:
nxt = 100
self._quick_scale(nxt)
def _toggle_autostart(self):
on = not bool(self.settings.get("autostart"))
if not self._set_autostart(on):
return
self.settings["autostart"] = on
config.save_settings(self.settings)
self.after_idle(self._rebuild_ui)
def _toggle_tray(self):
self.settings["minimize_to_tray_on_launch"] = not bool(
self.settings.get("minimize_to_tray_on_launch"))
config.save_settings(self.settings)
self.after_idle(self._rebuild_ui)
def _toggle_backup(self):
self.settings["auto_backup"] = not bool(self.settings.get("auto_backup"))
config.save_settings(self.settings)
self.after_idle(self._rebuild_ui)
def _quick_settings(self, parent):
"""Подменю быстрых настроек в главном меню.
Дублирует чекбоксы и выборы диалога «Настройки» (тема, автозагрузка,
трей, авто-бэкап, масштаб, язык): переключение мгновенное и с
сохранением — не нужно открывать настройки ради одного пункта.
Пересборку окна откладываем через after_idle: меню к этому моменту
уже закрыто, а _rebuild_ui уничтожает все виджеты главного окна.
"""
def sub():
return tk.Menu(parent, tearoff=0, bg=PANEL, fg=TEXT,
activebackground=HOVER, activeforeground=TEXT,
relief="flat", borderwidth=1,
font=("Segoe UI", 9))
qs = sub()
qs.configure(selectcolor=INPUT)
var_light = tk.BooleanVar(value=self.settings.get("theme") == "light")
def t_light():
self.settings["theme"] = "light" if var_light.get() else "dark"
config.save_settings(self.settings)
set_theme(self.settings["theme"])
self.after_idle(self._rebuild_ui)
qs.add_checkbutton(label=T("Светлая тема"), variable=var_light,
command=t_light)
var_auto = tk.BooleanVar(value=bool(self.settings.get("autostart")))
def t_auto():
if not self._set_autostart(bool(var_auto.get())):
var_auto.set(not var_auto.get()) # сбой — вернуть флажок
return
self.settings["autostart"] = bool(var_auto.get())
config.save_settings(self.settings)
qs.add_checkbutton(label=T("С автозагрузкой Windows"),
variable=var_auto, command=t_auto)
var_tray = tk.BooleanVar(
value=bool(self.settings.get("minimize_to_tray_on_launch")))
def t_tray():
self.settings["minimize_to_tray_on_launch"] = bool(var_tray.get())
config.save_settings(self.settings)
self._log(T("Настройки сохранены"), "ok")
qs.add_checkbutton(label=T("Прятать в трей при запуске игры"),
variable=var_tray, command=t_tray)
var_bak = tk.BooleanVar(value=bool(self.settings.get("auto_backup")))
def t_bak():
self.settings["auto_backup"] = bool(var_bak.get())
config.save_settings(self.settings)
self._log(T("Настройки сохранены"), "ok")
qs.add_checkbutton(label=T("Авто-бэкап мира перед запуском"),
variable=var_bak, command=t_bak)
cur_scale = int(float(self.settings.get("ui_scale", 1.0)) * 100)
sc_menu = sub()
for pct in (100, 125, 150):
sc_menu.add_command(
label=("● " if pct == cur_scale else "") + f"{pct}%",
command=lambda p=pct: self._quick_scale(p))
qs.add_cascade(label=T("Масштаб интерфейса:"), menu=sc_menu)
cur_lang = self.settings.get("lang", "ru")
lang_menu = sub()
for code, name in (("ru", "Русский"), ("en", "English")):
lang_menu.add_command(
label=("● " if code == cur_lang else "") + name,
command=lambda c=code: self._quick_lang(c))
qs.add_cascade(label=T("Язык:"), menu=lang_menu)
return qs
def _quick_scale(self, pct):
"""Масштаб интерфейса из быстрого меню — сразу и с сохранением."""
self.settings["ui_scale"] = pct / 100
config.save_settings(self.settings)
self._apply_ui_scale()
self.after_idle(self._rebuild_ui)
def _quick_lang(self, code):
"""Язык интерфейса из быстрого меню — сразу и с сохранением."""
if self.settings.get("lang") == code:
return
self.settings["lang"] = code
config.save_settings(self.settings)
set_language(code)
self.after_idle(self._rebuild_ui)
def _open_options(self):
d = self._dialog(T("Настройки"), width=640)
body = tk.Frame(d, bg=BG)
body.pack(fill="both", expand=True, padx=14, pady=10)
var_dir = tk.StringVar(value=self._game_dir())
self._field(body, T("Папка игры:"), var_dir, 34,
browse=lambda: self._pick_dir(var_dir))
self._hint(body, T("Здесь хранятся .minecraft, миры и файлы игры."))
row = tk.Frame(body, bg=BG)
row.pack(fill="x", pady=(10, 0))
self._btn(row, T("Открыть папку игры"),
lambda: self._open_explorer(self._game_dir())).pack(side="left")
self._btn(row, T("Открыть папку миров"),
lambda: self._open_explorer(
os.path.join(self._game_dir(), "saves"))).pack(side="left",
padx=(6, 0))
self._btn(row, T("Открыть скриншоты"),
lambda: self._open_explorer(
os.path.join(self._game_dir(), "screenshots"))).pack(
side="left", padx=(6, 0))
self._btn(row, T("Галерея"),
self._open_gallery).pack(side="left", padx=(6, 0))
# --- Язык
lang_row = tk.Frame(body, bg=BG)
lang_row.pack(fill="x", pady=(12, 0))
tk.Label(lang_row, text=T("Язык:"), bg=BG, fg=DIM, width=14,
anchor="w", font=("Segoe UI", 9)).pack(side="left")
var_lang = tk.StringVar(value=self.settings.get("lang", "ru"))
for code, name in (("ru", "Русский"), ("en", "English")):
tk.Radiobutton(lang_row, text=name, variable=var_lang, value=code,
bg=BG, fg=TEXT, selectcolor=INPUT, indicatoron=True,
activebackground=BG, activeforeground=TEXT,
highlightthickness=0,
font=("Segoe UI", 9)).pack(side="left", padx=(0, 10))
# --- Тема оформления
th_row = tk.Frame(body, bg=BG)
th_row.pack(fill="x", pady=(8, 0))
tk.Label(th_row, text=T("Тема:"), bg=BG, fg=DIM, width=14,
anchor="w", font=("Segoe UI", 9)).pack(side="left")
var_theme = tk.StringVar(value=self.settings.get("theme", "dark"))
for th_code, th_name in (("dark", T("Тёмная")),
("light", T("Светлая"))):
tk.Radiobutton(th_row, text=th_name, variable=var_theme,
value=th_code, bg=BG, fg=TEXT, selectcolor=INPUT,
indicatoron=True, activebackground=BG,
activeforeground=TEXT, highlightthickness=0,
font=("Segoe UI", 9)).pack(side="left", padx=(0, 10))
# --- Акцентный цвет
acc_row = tk.Frame(body, bg=BG)
acc_row.pack(fill="x", pady=(8, 0))
tk.Label(acc_row, text=T("Акцентный цвет:"), bg=BG, fg=DIM, width=14,
anchor="w", font=("Segoe UI", 9)).pack(side="left")
var_accent = tk.StringVar(value=self.settings.get("accent", GREEN))
for c in ("#6fae4a", "#3f9bd6", "#d6a53f", "#c95b5b", "#9b6fd6"):
sw = tk.Canvas(acc_row, width=18, height=18, bg=BG,
highlightthickness=1, highlightbackground=BAR_BD,
cursor="hand2")
sw.create_rectangle(1, 1, 17, 17, fill=c, outline="")
def pick(col=c, canvas=sw):
var_accent.set(col)
set_accent(col)
self.btn_play._draw()
self.progress._draw()
for w2 in canvas.master.winfo_children():
if isinstance(w2, tk.Canvas):
w2.configure(highlightbackground=BAR_BD)
canvas.configure(highlightbackground=c)
sw.bind("<Button-1>", lambda e, f=pick: f())
sw.pack(side="left", padx=(0, 6))
if c == var_accent.get():
sw.configure(highlightbackground=c)
# --- Автозагрузка и трей
run_row = tk.Frame(body, bg=BG)
run_row.pack(fill="x", pady=(10, 0))
tk.Label(run_row, text=T("Запуск:"), bg=BG, fg=DIM, width=14,
anchor="w", font=("Segoe UI", 9)).pack(side="left")
var_auto = tk.BooleanVar(value=bool(self.settings.get("autostart")))
tk.Checkbutton(run_row, text=T("С автозагрузкой Windows"),
variable=var_auto, bg=BG, fg=TEXT, selectcolor=INPUT,
activebackground=BG, activeforeground=TEXT,
highlightthickness=0,
font=("Segoe UI", 9)).pack(side="left")
var_tray_on = tk.BooleanVar(value=bool(
self.settings.get("minimize_to_tray_on_launch")))
tk.Checkbutton(run_row, text=T("Прятать в трей при запуске игры"),
variable=var_tray_on, bg=BG, fg=TEXT, selectcolor=INPUT,
activebackground=BG, activeforeground=TEXT,
highlightthickness=0,
font=("Segoe UI", 9)).pack(side="left", padx=(10, 0))
# --- Прокси (HTTP и SOCKS5 — своя реализация в http.py)
proxy = self.settings.get("proxy") or {}
tk.Label(body, text=T("Прокси:"), bg=BG, fg=DIM, anchor="w",
font=("Segoe UI", 9)).pack(anchor="w", pady=(12, 2))
px1 = tk.Frame(body, bg=BG)
px1.pack(fill="x")
var_px_on = tk.BooleanVar(value=bool(proxy.get("enabled")))
tk.Checkbutton(px1, text=T("Использовать прокси"),
variable=var_px_on, bg=BG, fg=TEXT, selectcolor=INPUT,
activebackground=BG, activeforeground=TEXT,
highlightthickness=0,
font=("Segoe UI", 9)).pack(side="left")
tk.Label(px1, text=T("Тип:"), bg=BG, fg=DIM,
font=("Segoe UI", 9)).pack(side="left", padx=(12, 3))
var_px_type = tk.StringVar(value=proxy.get("type", "http"))
ttk.Combobox(px1, textvariable=var_px_type, width=7, state="readonly",
values=("http", "socks5")).pack(side="left")
tk.Label(px1, text=T("Хост:"), bg=BG, fg=DIM,
font=("Segoe UI", 9)).pack(side="left", padx=(12, 3))
var_px_host = tk.StringVar(value=proxy.get("host", ""))
tk.Entry(px1, textvariable=var_px_host, width=16, font=("Segoe UI", 9),
bg=INPUT, fg=TEXT, insertbackground=TEXT, relief="solid",
bd=1, highlightthickness=0).pack(side="left")
tk.Label(px1, text=T("Порт:"), bg=BG, fg=DIM,
font=("Segoe UI", 9)).pack(side="left", padx=(8, 3))
var_px_port = tk.StringVar(value=str(proxy.get("port", 1080)))
tk.Entry(px1, textvariable=var_px_port, width=6, font=("Segoe UI", 9),
bg=INPUT, fg=TEXT, insertbackground=TEXT, relief="solid",
bd=1, highlightthickness=0).pack(side="left")
px2 = tk.Frame(body, bg=BG)
px2.pack(fill="x", pady=(3, 0))
tk.Label(px2, text=T("Логин:"), bg=BG, fg=DIM, width=14,
anchor="w", font=("Segoe UI", 9)).pack(side="left")
var_px_user = tk.StringVar(value=proxy.get("user", ""))
tk.Entry(px2, textvariable=var_px_user, width=14,
font=("Segoe UI", 9), bg=INPUT, fg=TEXT, insertbackground=TEXT,
relief="solid", bd=1, highlightthickness=0).pack(side="left",
padx=(0, 8))
tk.Label(px2, text=T("Пароль:"), bg=BG, fg=DIM,
font=("Segoe UI", 9)).pack(side="left")
var_px_pass = tk.StringVar(value=proxy.get("pass", ""))
tk.Entry(px2, textvariable=var_px_pass, width=14, show="•",
font=("Segoe UI", 9), bg=INPUT, fg=TEXT, insertbackground=TEXT,
relief="solid", bd=1, highlightthickness=0).pack(side="left",
padx=(3, 6))
self._btn(px2, T("Проверить прокси"),
lambda: self._test_proxy(d, var_px_on, var_px_type,
var_px_host, var_px_port,
var_px_user,
var_px_pass)).pack(side="left")
self._hint(body, T("HTTP-прокси и SOCKS5. Пустой хост = выключен."))
# --- Масштаб интерфейса
sc_row = tk.Frame(body, bg=BG)
sc_row.pack(fill="x", pady=(10, 0))
tk.Label(sc_row, text=T("Масштаб интерфейса:"), bg=BG, fg=DIM,
width=14, anchor="w", font=("Segoe UI", 9)).pack(side="left")
var_scale = tk.StringVar(
value=f"{int(float(self.settings.get('ui_scale', 1.0)) * 100)}%")
ttk.Combobox(sc_row, textvariable=var_scale, width=7, state="readonly",
values=("100%", "125%", "150%")).pack(side="left")
tk.Label(sc_row, text=T("Применится сразу (шрифты)."), bg=BG, fg=FAINT,
font=("Segoe UI", 8)).pack(side="left", padx=(8, 0))
# --- Авто-бэкап
var_backup = tk.BooleanVar(value=bool(self.settings.get("auto_backup")))
tk.Checkbutton(body, text=T("Авто-бэкап мира перед запуском"),
variable=var_backup, bg=BG, fg=TEXT, selectcolor=INPUT,
activebackground=BG, activeforeground=TEXT,
highlightthickness=0, font=("Segoe UI", 9)).pack(
anchor="w", pady=(10, 0))
# --- Зеркала скачивания
tk.Label(body, text=T("Зеркала ресурсов (по одному в строке, пусто = "
"официальные):"), bg=BG, fg=DIM, anchor="w",
font=("Segoe UI", 9)).pack(anchor="w", pady=(12, 2))
txt_assets = tk.Text(body, height=2, font=("Consolas", 9), bg=INPUT,
fg=TEXT, insertbackground=TEXT, relief="solid", bd=1,
highlightthickness=0)
txt_assets.pack(fill="x")
txt_assets.insert("1.0", "\n".join(
self.settings.get("asset_mirrors") or []))
tk.Label(body, text=T("Зеркала библиотек (по одному в строке, пусто = "
"официальные):"), bg=BG, fg=DIM, anchor="w",
font=("Segoe UI", 9)).pack(anchor="w", pady=(8, 2))
txt_libs = tk.Text(body, height=2, font=("Consolas", 9), bg=INPUT,
fg=TEXT, insertbackground=TEXT, relief="solid", bd=1,
highlightthickness=0)
txt_libs.pack(fill="x")
txt_libs.insert("1.0", "\n".join(
self.settings.get("lib_mirrors") or []))
btns = tk.Frame(body, bg=BG)
btns.pack(fill="x", pady=(12, 0))
def lines(widget):
return [x.strip() for x in widget.get("1.0", "end").splitlines()
if x.strip()]
def save():
path = var_dir.get().strip()
if path:
self.settings["game_dir"] = path
os.makedirs(path, exist_ok=True)
stats_mod.set_path(os.path.join(path, ".launcher"))
self.settings["lang"] = var_lang.get()
self.settings["accent"] = var_accent.get()
self.settings["theme"] = var_theme.get()
self.settings["asset_mirrors"] = lines(txt_assets)
self.settings["lib_mirrors"] = lines(txt_libs)
try:
px_port = int(var_px_port.get().strip() or 1080)
except ValueError:
px_port = 1080
self.settings["proxy"] = {
"enabled": bool(var_px_on.get()),
"type": var_px_type.get() if var_px_type.get() in
("http", "socks5") else "http",
"host": var_px_host.get().strip(),
"port": px_port,
"user": var_px_user.get().strip(),
"pass": var_px_pass.get(),
}
try:
self.settings["ui_scale"] = (float(var_scale.get()
.rstrip("%")) / 100)
except ValueError:
self.settings["ui_scale"] = 1.0
self.settings["auto_backup"] = bool(var_backup.get())
new_auto = bool(var_auto.get())
if new_auto != bool(self.settings.get("autostart")):
self._set_autostart(new_auto)
self.settings["autostart"] = new_auto
self.settings["minimize_to_tray_on_launch"] = bool(
var_tray_on.get())
config.save_settings(self.settings)
set_language(self.settings["lang"])
set_theme(self.settings["theme"])
set_accent(self.settings["accent"])
http.configure(self.settings.get("proxy"))
self._apply_ui_scale()
self._rebuild_ui()
d.destroy()
self._log(T("Настройки сохранены"), "ok")
self._btn(btns, T("Сохранить"), save, primary=True).pack(side="right")
self._btn(btns, T("Отмена"), d.destroy).pack(side="right", padx=(0, 6))
self._finish_dialog(d)
def _open_modrinth_packs(self):
"""Сборки Modrinth: поиск, выбор версии, установка."""
self._open_pack_browser("modrinth")
def _open_curseforge_packs(self):
"""Сборки CurseForge: поиск, выбор файла, установка."""
self._open_pack_browser("curseforge")
def _open_pack_browser(self, source):
"""Диалог сборок: Modrinth (source='modrinth') или CurseForge."""
is_mr = source == "modrinth"
d = self._dialog(T("Modrinth: сборки") if is_mr
else T("CurseForge: сборки"), width=700)
body = tk.Frame(d, bg=BG)
body.pack(fill="both", expand=True, padx=12, pady=10)
top = tk.Frame(body, bg=BG)
top.pack(fill="x")
var_q = tk.StringVar()
ent = tk.Entry(top, textvariable=var_q, width=42, font=("Segoe UI", 9),
bg=INPUT, fg=TEXT, insertbackground=TEXT, relief="solid",
bd=1, highlightbackground=INPUT_BD, highlightthickness=1)
ent.pack(side="left", ipady=2)
ent.bind("<FocusIn>", lambda e: ent.configure(highlightbackground=GREEN))
ent.bind("<FocusOut>",
lambda e: ent.configure(highlightbackground=INPUT_BD))
tk.Label(top, text=T("Поиск сборок"), bg=BG, fg=DIM,
font=("Segoe UI", 9)).pack(side="left", padx=(8, 0))
status = tk.Label(body, text=T("Загрузка…"), bg=BG, fg=DIM, anchor="w",
font=("Segoe UI", 8))
status.pack(fill="x", pady=(8, 0))
lb = tk.Listbox(body, height=9, bg=INPUT, fg=TEXT, selectbackground=GREEN,
selectforeground="#0d1a0d", relief="solid", bd=1,
highlightthickness=0, font=("Segoe UI", 9),
exportselection=False)
lb.pack(fill="both", expand=True, pady=(4, 0))
lbl_files = tk.Label(body, text=T("Файлы сборки"), bg=BG, fg=DIM,
anchor="w", font=("Segoe UI", 8))
lbl_files.pack(fill="x", pady=(8, 0))
lb_f = tk.Listbox(body, height=6, bg=INPUT, fg=TEXT,
selectbackground=GREEN, selectforeground="#0d1a0d",
relief="solid", bd=1, highlightthickness=0,
font=("Segoe UI", 9), exportselection=False)
lb_f.pack(fill="both", expand=True, pady=(4, 0))
packs = []
files = []
timer = {"id": None}
def run(fn, ok, err):
"""Выполняет fn в фоне, результат отдаёт в GUI-поток."""
state = {"done": False, "data": None, "error": None}
def go():
try:
state["data"] = fn()
except Exception as e: # noqa: BLE001
state["error"] = e
state["done"] = True
threading.Thread(target=go, daemon=True).start()
def wait():
try:
if not d.winfo_exists():
return
except tk.TclError:
return
if not state["done"]:
d.after(120, wait)
return
if state["error"] is not None:
err(state["error"])
else:
ok(state["data"])
wait()
def fmt_size(n):
n = int(n or 0)
if n <= 0:
return ""
if n >= 1024 * 1024:
return "%.1f %s" % (n / (1024.0 * 1024.0), T("МБ"))
if n >= 1024:
return "%d %s" % (n // 1024, T("КБ"))
return "%d %s" % (n, T("Б"))
def search_packs():
q = var_q.get().strip()
status.configure(text=T("Загрузка…"), fg=DIM)
def ok(res):
nonlocal packs
out, total = res
packs = out
lb.delete(0, "end")
for it in out:
title = it.get("title") or it.get("name") or "?"
desc = it.get("desc") or it.get("summary") or ""
bits = []
if it.get("downloads"):
bits.append(str(it["downloads"]))
if it.get("loaders"):
bits.append(", ".join(it["loaders"][:2]))
tail = (" [" + " · ".join(bits) + "]") if bits else ""
lb.insert("end", "%s — %s%s" % (title, desc, tail))
status.configure(text=T("Найдено: ") + str(total), fg=DIM)
def err(e):
status.configure(text=T("Ошибка: ") + str(e), fg=ERR)
if is_mr:
api = lambda: modrinth_mod.search_modpacks(q, limit=30)
else:
api = lambda: curseforge_mod.search_modpacks(q, page_size=30)
run(api, ok, err)
def schedule_search(*_a):
if timer["id"] is not None:
try:
d.after_cancel(timer["id"])
except tk.TclError:
pass
timer["id"] = d.after(400, search_packs)
def load_files(*_a):
sel = lb.curselection()
lb_f.delete(0, "end")
files.clear()
if not sel or sel[0] >= len(packs):
lbl_files.configure(text=T("Файлы сборки"), fg=DIM)
return
p = packs[sel[0]]
status.configure(text=T("Загрузка…"), fg=DIM)
def ok(res):
files.clear()
files.extend(res)
lb_f.delete(0, "end")
for f in res:
bits = []
gv = f.get("game_versions") or []
ld = f.get("loaders") or []
if gv:
bits.append(", ".join(str(x) for x in gv[:3]))
if ld:
bits.append(", ".join(str(x) for x in ld[:2]))
if f.get("size"):
bits.append(fmt_size(f["size"]))
if f.get("date"):
bits.append(str(f["date"]))
name = f.get("name") or f.get("file_name") or "?"
tail = (" [" + " · ".join(bits) + "]") if bits else ""
lb_f.insert("end", "%s%s" % (name, tail))
lbl_files.configure(
text=T("Файлы сборки") + ": " + str(len(files)), fg=DIM)
status.configure(text=T("Найдено: ") + str(len(files)), fg=DIM)
def err(e):
status.configure(text=T("Ошибка: ") + str(e), fg=ERR)
if is_mr:
pid = p.get("slug") or p.get("id")
api = lambda: modrinth_mod.get_modpack_versions(pid)
else:
api = lambda: curseforge_mod.get_files(p.get("id"))
run(api, ok, err)
def install_sel():
sel = lb.curselection()
if not sel or sel[0] >= len(packs):
status.configure(text=T("Выберите сборку"), fg=ERR)
return
p = packs[sel[0]]
fsel = lb_f.curselection()
f = files[fsel[0]] if (fsel and fsel[0] < len(files)) else None
title = p.get("title") or p.get("name") or "?"
d.destroy()
if is_mr:
pid = p.get("slug") or p.get("id")
ver = (f or {}).get("id")
def worker(log, prog, cancel):
return packs_mod.install_pack_modrinth(
pid, game_dir=self._game_dir(), version=ver, log=log,
progress=prog, cancel=cancel)
else:
mid = p.get("id")
fid = (f or {}).get("id") or p.get("file_id")
def worker(log, prog, cancel):
return packs_mod.install_pack_curseforge(
mid, fid, game_dir=self._game_dir(), log=log,
progress=prog, cancel=cancel)
self._install_pack_async(
worker, T("Установка сборки: ") + str(title))
var_q.trace_add("write", schedule_search)
lb.bind("<<ListboxSelect>>", load_files)
lb_f.bind("<Double-Button-1>", lambda e: install_sel())
lb.bind("<Double-Button-1>", lambda e: install_sel())
btns = tk.Frame(body, bg=BG)
btns.pack(fill="x", pady=(10, 0))
self._btn(btns, T("Установить"), install_sel, primary=True).pack(
side="left")
self._btn(btns, T("Обновить список"), search_packs).pack(
side="left", padx=(6, 0))
self._btn(btns, T("Закрыть"), d.destroy).pack(side="right")
search_packs()
self._finish_dialog(d)
def _install_pack_async(self, worker, label):
"""Ставит сборку в фоне: прогресс и лог — в главном окне."""
if self.busy:
return
self.busy = True
self._cancel = threading.Event()
cancel = self._cancel
self.btn_play.set_state("disabled")
self.btn_cancel.configure(state="normal")
self.btn_cancel.pack()
self.progress.set(0)
def bg():
log = lambda m, t=None: self.ui_q.put(("log", m, t)) # noqa: E731
def prog(f):
self.ui_q.put(("progress", f * 100, 100, T("Сборка")))
try:
log(label)
vid, got = worker(log, prog, cancel)
if vid:
self.ui_q.put(("use_version", vid))
self.ui_q.put(("reload_versions",))
log(T("Модпак установлен") + (f" ({got})" if got else ""), "ok")
except http.Cancelled:
log(T("Загрузка отменена"), "warn")
except Exception as e: # noqa: BLE001
log(T("Ошибка: ") + str(e), "err")
finally:
self.ui_q.put(("done",))
threading.Thread(target=bg, daemon=True).start()
def _rebuild_ui(self):
"""Пересобирает главное окно (после смены языка/настроек)."""
for c in self.winfo_children():
if isinstance(c, tk.Toplevel):
continue
try:
c.destroy()
except tk.TclError:
pass
self._log_txt_widget = None
self.configure(bg=BG)
self._build()
self._load_settings_to_ui()
self._update_pill()
self._fill_news(self._news)
if self.busy:
self.btn_play.set_state("disabled")
self.btn_cancel.pack()
nick = self.var_nick.get().strip()
if re.fullmatch(r"[A-Za-z0-9_]{3,16}", nick):
threading.Thread(target=self._load_head_bg, args=(nick,),
daemon=True).start()
def _open_log(self):
d = self._dialog(T("Журнал"), width=620)
body = tk.Frame(d, bg=BG)
body.pack(fill="both", expand=True, padx=12, pady=10)
# --- источник: живой сеанс или архивный файл игры
logs_dir = os.path.join(self._game_dir(), ".launcher", "logs")
try:
files = sorted((f for f in os.listdir(logs_dir)
if f.startswith("game-") and f.endswith(".log")),
reverse=True)[:40]
except OSError:
files = []
top = tk.Frame(body, bg=BG)
top.pack(fill="x")
tk.Label(top, text=T("Архив логов:"), bg=BG, fg=DIM,
font=("Segoe UI", 9)).pack(side="left")
var_src = tk.StringVar(value=T("Сеанс (живой)"))
ttk.Combobox(top, textvariable=var_src, width=38, state="readonly",
values=[T("Сеанс (живой)")] + files).pack(side="left",
padx=(6, 0))
txt = tk.Text(body, bg=INPUT, fg=DIM, font=("Consolas", 9),
relief="solid", bd=1, highlightthickness=0, state="disabled",
wrap="word", width=70, height=15)
txt.pack(fill="both", expand=True, pady=(8, 0))
txt.tag_configure("ok", foreground=OK)
txt.tag_configure("err", foreground=ERR)
txt.tag_configure("warn", foreground=WARN)
txt.tag_configure("ts", foreground=FAINT)
if THEME == "light":
txt.tag_configure("game", foreground=GREEN_LO)
txt.tag_configure("hit", background=mix(GREEN, "#ffffff", 0.75),
foreground=TEXT)
else:
txt.tag_configure("game", foreground="#a8c48a")
txt.tag_configure("hit", background="#4a5a3a",
foreground="#ffffff")
txt.tag_lower("hit")
self._log_txt_widget = txt
self._log_view_mode = "session"
# --- фильтр «только ошибки» + поиск Ctrl+F
var_err = tk.BooleanVar(value=False)
err_keys = ("error", "exception", "fatal", "caused by",
"outofmemory", "crash", "ошибка", "фатальн")
def render_file():
if self._log_view_mode == "session":
return
lines_ = self._log_orig_lines
if var_err.get():
lines_ = [ln for ln in lines_
if any(k in ln.lower() for k in err_keys)]
txt.configure(state="normal")
txt.delete("1.0", "end")
for ln in lines_[:6000]:
txt.insert("end", ln + "\n", "game")
txt.configure(state="disabled")
def load_src(*_a):
src = var_src.get()
self._find_state.update(idx=-1, n=0)
txt.tag_remove("hit", "1.0", "end")
if src == T("Сеанс (живой)"):
self._log_view_mode = "session"
self._log_orig_lines = []
err_chk.configure(state="disabled")
txt.configure(state="normal")
txt.delete("1.0", "end")
for ts, msg, tag in self._log_lines:
txt.insert("end", ts + " ", "ts")
txt.insert("end", msg + "\n", tag or ())
txt.configure(state="disabled")
lbl_cnt.configure(text="")
else:
self._log_view_mode = src
err_chk.configure(state="normal")
try:
with open(os.path.join(logs_dir, src), encoding="utf-8",
errors="replace") as f:
self._log_orig_lines = f.read().splitlines()
except OSError as e:
self._log_orig_lines = ["<" + str(e) + ">"]
render_file()
lbl_cnt.configure(text=f"{len(self._log_orig_lines)} "
+ T("строка"))
var_src.trace_add("write", load_src)
find_row = tk.Frame(body, bg=BG)
find_row.pack(fill="x", pady=(8, 0))
tk.Label(find_row, text=T("Найти:"), bg=BG, fg=DIM,
font=("Segoe UI", 9)).pack(side="left")
var_find = tk.StringVar()
e_find = tk.Entry(find_row, textvariable=var_find, width=26,
font=("Segoe UI", 9), bg=INPUT, fg=TEXT,
insertbackground=TEXT, relief="solid", bd=1,
highlightthickness=0)
e_find.pack(side="left", padx=(4, 6))
lbl_cnt = tk.Label(find_row, text="", bg=BG, fg=DIM,
font=("Segoe UI", 8))
err_chk = tk.Checkbutton(find_row, text=T("только ошибки"),
variable=var_err, command=render_file,
bg=BG, fg=DIM, selectcolor=INPUT,
activebackground=BG, activeforeground=TEXT,
highlightthickness=0, state="disabled",
font=("Segoe UI", 8))
err_chk.pack(side="right")
def goto(delta):
q = var_find.get()
matches = []
if q:
pos = "1.0"
while True:
pos = txt.search(q, pos, stopindex="end", nocase=True)
if not pos:
break
end = "%s+%dc" % (pos, len(q))
matches.append((pos, end))
pos = end
txt.tag_remove("hit", "1.0", "end")
if not matches:
self._find_state.update(idx=-1, n=0)
lbl_cnt.configure(text=T("Нет совпадений"), fg=ERR)
return
idx = (self._find_state.get("idx", -1) + delta) % len(matches)
self._find_state.update(idx=idx, n=len(matches))
a, b = matches[idx]
txt.tag_add("hit", a, b)
txt.see(a)
lbl_cnt.configure(text=T("Найдено: ") + f"{idx + 1}/{len(matches)}",
fg=DIM)
e_find.bind("<Return>", lambda e: goto(1))
e_find.bind("<Shift-Return>", lambda e: goto(-1))
txt.bind("<Control-f>", lambda e: (e_find.focus_set(), "break")[1])
self._btn(find_row, "▶", lambda: goto(1)).pack(side="left", padx=2)
self._btn(find_row, "◀", lambda: goto(-1)).pack(side="left")
load_src() # показать текущий сеанс сразу при открытии
def on_close():
d.destroy()
try:
if self._log_txt_widget is txt:
self._log_txt_widget = None
self._log_view_mode = "session"
except tk.TclError:
self._log_txt_widget = None
self._log_view_mode = "session"
d.protocol("WM_DELETE_WINDOW", on_close)
def copy_all():
try:
self.clipboard_clear()
self.clipboard_append(txt.get("1.0", "end-1c"))
except tk.TclError:
pass
txt.bind("<Control-c>", lambda e: (copy_all(), "break")[1])
btns = tk.Frame(body, bg=BG)
btns.pack(fill="x", pady=(10, 0))
self._btn(btns, T("Копировать"), copy_all).pack(side="left")
self._btn(btns, T("Обновить"), load_src).pack(side="left", padx=(6, 0))
self._btn(btns, T("Закрыть"), on_close, primary=True).pack(side="right")
self._finish_dialog(d)
def _journal_open(self):
txt = self._log_txt_widget
return txt is not None and txt.winfo_exists()
# ------------------------------------------------------------ новшества
def _apply_ui_scale(self):
"""Масштаб шрифтов (tk scaling) из настроек — сразу, без перезапуска."""
try:
sc = float(self.settings.get("ui_scale", 1.0))
except (TypeError, ValueError):
sc = 1.0
self.tk.call("tk", "scaling", self._base_scaling * sc)
def _test_proxy(self, d, var_on, var_type, var_host, var_port,
var_user, var_pass):
"""Пробный запрос через прокси из формы (результат — в журнал)."""
try:
port = int(var_port.get().strip() or 1080)
except ValueError:
port = 1080
cfg = {"enabled": True,
"type": var_type.get() if var_type.get() in ("http", "socks5")
else "http",
"host": var_host.get().strip(), "port": port,
"user": var_user.get(),
"pass": var_pass.get()}
def bg():
http.configure(cfg)
try:
http.get_bytes("https://api.modrinth.com/v2/search?limit=1",
timeout=12)
ok, err = True, ""
except Exception as e: # noqa: BLE001
ok, err = False, str(e)
finally:
http.configure(self.settings.get("proxy"))
self.ui_q.put(("log", T("Прокси работает") if ok
else T("Прокси не работает: ") + err,
"ok" if ok else "err"))
threading.Thread(target=bg, daemon=True).start()
def _version_context(self):
"""(mc-версия, лоадер) выбранной версии: fabric-*/quilt-* — локальные."""
vid = self._selected_version_id()
m = re.match(r"(fabric|quilt)-loader-.+-(\d[\w.\-]*)$", vid)
if m:
return m.group(2), m.group(1)
return vid, ""
def _startup_mod_updates(self):
"""Тихая фоновая проверка обновлений модов при старте."""
def bg():
try:
mc, loader = self._version_context()
mc_f = mc if re.match(r"\d", mc) else ""
q_loader = loader if loader in ("fabric", "quilt") else ""
if not q_loader or not mods_mod.list_mods(self._game_dir()):
return
ups = mods_mod.check_updates(self._game_dir(), mc_f, q_loader)
if ups and not self.busy:
self.ui_q.put(("log",
T("Есть обновления модов: {n}. "
"Откройте «Моды» для обновления.")
.format(n=len(ups)), "warn"))
except Exception: # noqa: BLE001
pass
threading.Thread(target=bg, daemon=True).start()
def _open_mods(self):
mc, loader = self._version_context()
d = self._dialog(T("Моды"), width=600)
body = tk.Frame(d, bg=BG)
body.pack(fill="both", expand=True, padx=14, pady=10)
lq = queue.Queue()
state = {"hits": []}
mc_f = mc if re.match(r"\d", mc) else ""
q_loader = loader if loader in ("fabric", "quilt") else ""
def poll():
if not d.winfo_exists():
return
try:
while True:
msg = lq.get_nowait()
if msg[0] == "search":
state["hits"] = msg[1]
res_lb.delete(0, "end")
for h in state["hits"]:
res_lb.insert(
"end", f"{h['title']} · "
f"{h['downloads']:,}".replace(",", " ")
+ " " + T("загрузок"))
if state["hits"]:
res_lb.selection_set(0)
show_desc()
lbl_desc.configure(
text=T("Ничего не найдено")
if not state["hits"] else "")
elif msg[0] == "updates":
show_updates(msg[1])
elif msg[0] == "mods":
refresh_local()
elif msg[0] == "log":
self._log(msg[1], msg[2] if len(msg) > 2 else None)
except queue.Empty:
pass
d.after(80, poll)
d.after(80, poll)
# --- поиск
head = tk.Frame(body, bg=BG)
head.pack(fill="x")
tk.Label(head, text=T("Поиск модов на Modrinth:") + " "
+ T("Моды для {v}").format(v=mc)
+ (" · " + q_loader if q_loader else ""), bg=BG, fg=DIM,
font=("Segoe UI", 9)).pack(anchor="w", pady=(0, 4))
row = tk.Frame(body, bg=BG)
row.pack(fill="x")
var_q = tk.StringVar()
e = tk.Entry(row, textvariable=var_q, font=("Segoe UI", 9),
bg=INPUT, fg=TEXT, insertbackground=TEXT, relief="solid",
bd=1, highlightthickness=1, highlightbackground=INPUT_BD)
e.pack(side="left", fill="x", expand=True, ipady=2)
e.focus_set()
def do_search(*_a):
q = var_q.get().strip() # читаем в главном потоке (Tk не потокобезопасен)
def bg():
try:
hits, _total = modrinth_mod.search(q, mc_f, q_loader)
except Exception as e_: # noqa: BLE001
hits = []
lq.put(("log", T("Ошибка: ") + str(e_), "err"))
lq.put(("search", hits))
lq.put(("log", T("Идёт поиск…")))
threading.Thread(target=bg, daemon=True).start()
self._btn(row, T("Искать"), do_search).pack(side="left", padx=(6, 0))
e.bind("<Return>", lambda x: do_search())
res_lb = tk.Listbox(body, height=7, bg=INPUT, fg=TEXT,
selectbackground=GREEN, selectforeground="#0d1a0d",
relief="solid", bd=1, highlightthickness=0,
font=("Segoe UI", 9), exportselection=False)
res_lb.pack(fill="x", pady=(8, 0))
lbl_desc = tk.Label(body, text="", bg=BG, fg=DIM, anchor="w",
justify="left", wraplength=560, height=3,
font=("Segoe UI", 8))
def show_desc(*_a):
sel = res_lb.curselection()
if sel and sel[0] < len(state["hits"]):
lbl_desc.configure(text=state["hits"][sel[0]]["desc"][:280])
res_lb.bind("<<ListboxSelect>>", show_desc)
lbl_desc.pack(fill="x", pady=(2, 0))
def install_mod():
sel = res_lb.curselection()
if not sel or sel[0] >= len(state["hits"]):
return
h = state["hits"][sel[0]]
def bg():
try:
vs = modrinth_mod.versions(h["id"], mc_f, q_loader)
if not vs:
lq.put(("log", T("Ничего не найдено"), "warn"))
return
v = vs[0]
dest = os.path.join(mods_mod.mods_dir(self._game_dir()),
v["filename"])
lq.put(("log", T("Установка: ") + v["filename"]))
ok = modrinth_mod.download_mod(v["url"], dest,
size=v["size"])
lq.put(("log", (T("Мод установлен: ") + v["filename"])
if ok else T("Ошибка: ") + v["filename"],
"ok" if ok else "err"))
except Exception as e_: # noqa: BLE001
lq.put(("log", T("Ошибка: ") + str(e_), "err"))
finally:
lq.put(("mods",))
threading.Thread(target=bg, daemon=True).start()
self._btn(body, T("Установить"), install_mod).pack(anchor="w",
pady=(4, 0))
# --- установленные моды
tk.Label(body, text=T("Установленные моды"), bg=BG, fg=TEXT,
font=("Segoe UI", 9, "bold")).pack(anchor="w", pady=(10, 2))
loc_lb = tk.Listbox(body, height=5, bg=INPUT, fg=TEXT,
selectbackground=GREEN, selectforeground="#0d1a0d",
relief="solid", bd=1, highlightthickness=0,
font=("Consolas", 9), exportselection=False)
loc_lb.pack(fill="x")
def refresh_local():
loc_lb.delete(0, "end")
state["local"] = mods_mod.list_mods(self._game_dir())
for m in state["local"]:
loc_lb.insert("end", ("● " if m["enabled"] else "○ ")
+ m["name"] + " (" + mods_mod.fmt_size(
m["size"]) + ")")
refresh_local()
def selected_mod():
sel = loc_lb.curselection()
if not sel or sel[0] >= len(state.get("local") or []):
return None
return state["local"][sel[0]]
def toggle_mod():
m = selected_mod()
if not m:
return
new = mods_mod.set_enabled(self._game_dir(), m["file"],
not m["enabled"])
if new:
self._log((T("Мод включён: ") if not m["enabled"]
else T("Мод отключён: ")) + m["name"], "ok")
refresh_local()
def delete_mod():
m = selected_mod()
if not m:
return
if not messagebox.askyesno(
"PixelLauncher",
T("Удалить") + " " + m["name"] + "?", parent=d):
return
if mods_mod.remove_mod(self._game_dir(), m["file"]):
self._log(T("Мод удалён: ") + m["name"], "ok")
refresh_local()
def api_mod():
if not q_loader:
self._log(T("Нет Fabric/Quilt-версии — API не ставится."),
"warn")
return
def bg():
n = modrinth_mod.ensure_loader_api(
self._game_dir(), mc_f, q_loader,
log=lambda m_: lq.put(("log", m_)))
if not n:
lq.put(("log", T("Уже установлено"), "ok"))
lq.put(("mods",))
threading.Thread(target=bg, daemon=True).start()
# --- проверка обновлений установленных модов
state["updates"] = []
def check_updates():
lq.put(("log", T("Проверка обновлений…")))
def bg():
try:
ups = mods_mod.check_updates(
self._game_dir(), mc_f, q_loader,
log=lambda m_: lq.put(("log", m_)))
except Exception as err: # noqa: BLE001
ups = []
lq.put(("log", T("Ошибка: ") + str(err), "err"))
lq.put(("updates", ups))
threading.Thread(target=bg, daemon=True).start()
def show_updates(ups):
state["updates"] = ups
if not ups:
self._log(T("Обновлений не найдено"), "ok")
return
self._log(T("Обновлений: ") + str(len(ups)), "warn")
for u in ups:
self._log("→ " + u["name"] + ": "
+ (u["cur"] or "?") + " → " + u["new_ver"], "warn")
ud = self._dialog(T("Обновления модов"), width=520)
ubody = tk.Frame(ud, bg=BG)
ubody.pack(fill="both", expand=True, padx=14, pady=10)
ulb = tk.Listbox(ubody, height=min(10, len(ups)), bg=INPUT, fg=TEXT,
selectbackground=GREEN, selectforeground="#0d1a0d",
relief="solid", bd=1, highlightthickness=0,
font=("Consolas", 9), exportselection=False)
ulb.pack(fill="both", expand=True)
for u in ups:
ulb.insert("end", f"{u['name']:<28} "
f"{u['cur'] or '?'} → {u['new_ver']}")
if ups:
ulb.selection_set(0)
def apply_updates(indices):
picked = [ups[i] for i in indices
if 0 <= i < len(ups)]
if not picked:
return
ud.destroy()
def bg():
ok_n = 0
for u in picked:
lq.put(("log", T("Обновление: ") + u["new_file"]))
if mods_mod.apply_update(self._game_dir(), u):
ok_n += 1
lq.put(("log", T("Мод обновлён: ") + u["name"],
"ok"))
else:
lq.put(("log", T("Не удалось обновить: ")
+ u["new_file"], "err"))
lq.put(("log", T("Обновлено: ") + str(ok_n) + "/"
+ str(len(picked)), "ok" if ok_n == len(picked)
else "warn"))
lq.put(("mods",))
threading.Thread(target=bg, daemon=True).start()
ubtns = tk.Frame(ubody, bg=BG)
ubtns.pack(fill="x", pady=(10, 0))
self._btn(ubtns, T("Закрыть"), ud.destroy).pack(side="right")
self._btn(ubtns, T("Обновить всё"),
lambda: apply_updates(range(len(ups)))).pack(
side="right", padx=(0, 6))
def update_sel():
sel = ulb.curselection()
if sel:
apply_updates(list(sel))
self._btn(ubtns, T("Обновить выбранное"), update_sel).pack(
side="left")
self._finish_dialog(ud)
brow = tk.Frame(body, bg=BG)
brow.pack(fill="x", pady=(6, 0))
self._btn(brow, T("Включить") + "/" + T("Выключить"),
toggle_mod).pack(side="left")
self._btn(brow, T("Удалить"), delete_mod).pack(side="left",
padx=(6, 0))
self._btn(brow, T("Установить Fabric API"),
api_mod).pack(side="left", padx=(6, 0))
self._btn(brow, T("Проверить обновления"),
check_updates).pack(side="left", padx=(6, 0))
self._btn(brow, T("Каталог модпаков…"),
self._open_packs).pack(side="left", padx=(6, 0))
self._btn(brow, T("Папка mods"), lambda: self._open_explorer(
mods_mod.mods_dir(self._game_dir()))).pack(side="right")
self._finish_dialog(d)
return d
def _import_pack(self, path=None):
"""Импорт модпака .mrpack: лоадер + файлы + overrides."""
if self.busy:
return
if path is None:
path = filedialog.askopenfilename(
parent=self, title=T("Модпаки"),
filetypes=[("mrpack", "*.mrpack"), (T("Все файлы"), "*.*")])
if not path:
return
self.busy = True
self._cancel = threading.Event()
self.btn_play.set_state("disabled")
self.btn_cancel.configure(state="normal")
self.btn_cancel.pack()
self.progress.set(0)
def bg():
log = lambda m, t=None: self.ui_q.put(("log", m, t)) # noqa: E731
try:
log(T("Установка модпака: ") + os.path.basename(path))
vid, got = mrpack_mod.install(
self._game_dir(), path, log=log,
progress=lambda f: self.ui_q.put(
("progress", f * 100, 100, T("Модпак"))),
cancel=self._cancel)
log(T("Модпак установлен: ") + os.path.basename(path)
+ f" ({got})", "ok")
if vid:
self.ui_q.put(("use_version", vid))
self.ui_q.put(("reload_versions",))
except http.Cancelled:
log(T("Загрузка отменена"), "warn")
except Exception as e: # noqa: BLE001
log(T("Ошибка: ") + str(e), "err")
finally:
self.ui_q.put(("done",))
threading.Thread(target=bg, daemon=True).start()
def _open_history(self):
d = self._dialog(T("История и статистика"), width=560)
body = tk.Frame(d, bg=BG)
body.pack(fill="both", expand=True, padx=14, pady=10)
def fill():
t = stats_mod.totals()
lbl_top.configure(
text=T("Всего времени: ")
+ stats_mod.fmt_duration(t["total_seconds"])
+ " · " + T("Запусков: ") + str(t["total_launches"]))
lb1.delete(0, "end")
for r in t["per_version"]:
lb1.insert("end", f"{r['ver']:<26} "
+ str(r["count"]) + " " + T("шт.") + " · "
+ stats_mod.fmt_duration(r["seconds"]) + " · "
+ stats_mod.fmt_time(r["last"])
+ (f" · {r['crashes']} " + T("вылетов")
if r["crashes"] else ""))
lb2.delete(0, "end")
for r in t["recent"]:
ok_txt = T("Ок") if r.get("ok") else f"{T('вылет')} ({r.get('code')})"
lb2.insert("end", stats_mod.fmt_time(r.get("ts")) + " · "
+ str(r.get("ver")) + " · " + ok_txt + " · "
+ stats_mod.fmt_duration(r.get("seconds")))
lbl_top = tk.Label(body, text="", bg=BG, fg=TEXT, anchor="w",
font=("Segoe UI", 10, "bold"))
lbl_top.pack(fill="x")
lb1 = tk.Listbox(body, height=7, bg=INPUT, fg=TEXT, relief="solid",
bd=1, highlightthickness=0, font=("Consolas", 9),
exportselection=False)
lb1.pack(fill="x", pady=(8, 0))
tk.Label(body, text=T("Последние запуски"), bg=BG, fg=DIM,
font=("Segoe UI", 9)).pack(anchor="w", pady=(10, 2))
lb2 = tk.Listbox(body, height=6, bg=INPUT, fg=DIM, relief="solid",
bd=1, highlightthickness=0, font=("Consolas", 9),
exportselection=False)
lb2.pack(fill="x")
fill()
def clear_hist():
if not messagebox.askyesno("PixelLauncher",
T("Очистить историю") + "?",
parent=d):
return
stats_mod.clear()
fill()
self._log(T("История очищена"), "ok")
btns = tk.Frame(body, bg=BG)
btns.pack(fill="x", pady=(12, 0))
self._btn(btns, T("Очистить историю"), clear_hist).pack(side="left")
self._btn(btns, T("Закрыть"), d.destroy, primary=True).pack(
side="right")
self._finish_dialog(d)
def _open_skin(self):
d = self._dialog(T("Скин-превью"), width=300)
body = tk.Frame(d, bg=BG)
body.pack(fill="both", expand=True, padx=14, pady=10)
var_nick = tk.StringVar(value=self.var_nick.get())
row = tk.Frame(body, bg=BG)
row.pack(fill="x")
tk.Label(row, text=T("Ник:"), bg=BG, fg=DIM,
font=("Segoe UI", 9)).pack(side="left")
e = tk.Entry(row, textvariable=var_nick, width=16,
font=("Segoe UI", 9), bg=INPUT, fg=TEXT,
insertbackground=TEXT, relief="solid", bd=1,
highlightthickness=0)
e.pack(side="left", padx=(4, 6), ipady=2)
e.focus_set()
Z = 8
cv = tk.Canvas(body, width=16 * Z + 8, height=32 * Z + 8, bg="#0c0c0c",
highlightthickness=1, highlightbackground=INPUT_BD)
cv.pack(pady=(10, 0))
refs = []
def render(path):
if not cv.winfo_exists():
return
try:
img = tk.PhotoImage(file=path)
except tk.TclError:
self._log(T("Скин не найден"), "err")
return
refs.clear()
refs.append(img) # PhotoImage живёт, пока ссылка в списке
cv.delete("all")
dec = _png_rgba(path) # альфа из PNG (tk photo get её не отдаёт)
cells = {} # клетка -> (r, g, b) с учётом наложений
for sx, sy, ex, ey, dx, dy in _skin_parts(img.height() >= 64):
for j in range(ey - sy):
for i in range(ex - sx):
if dec:
off = ((sy + j) * dec[0] + sx + i) * 4
rgba = dec[2][off:off + 4]
rgb = (rgba[0], rgba[1], rgba[2])
alpha = rgba[3]
else:
try:
px = img.get(sx + i, sy + j)
except tk.TclError:
return
rgb = tuple(px[:3])
# без альфы: чистый чёрный — вероятная прозрачность
alpha = 0 if rgb == (0, 0, 0) else 255
if not alpha:
continue
key = (dx + i, dy + j)
if alpha >= 255:
cells[key] = rgb
else:
base = cells.get(key)
if base is None:
cells[key] = rgb
else: # полупрозрачный оверлей поверх базы
cells[key] = tuple(
(rgb[k] * alpha + base[k] * (255 - alpha))
// 255 for k in range(3))
for (gx, gy), rgb in cells.items():
x0 = 4 + gx * Z
y0 = 4 + gy * Z
cv.create_rectangle(x0, y0, x0 + Z, y0 + Z,
fill="#%02x%02x%02x" % rgb, outline="")
self._log(T("Скин загружен"), "ok")
def load_nick():
nick = var_nick.get().strip()
if not re.fullmatch(r"[A-Za-z0-9_]{3,16}", nick):
self._log(T("Ник: 3-16 символов, латиница, цифры и _"), "warn")
return
cache = os.path.join(self._game_dir(), ".launcher", "heads",
nick + ".skin.png")
try:
fresh = (os.path.isfile(cache) and time.time()
- os.path.getmtime(cache) < 86400)
if not fresh:
data = http.get_bytes(
f"https://mc-heads.net/skin/{nick}", timeout=10)
os.makedirs(os.path.dirname(cache), exist_ok=True)
with open(cache, "wb") as f:
f.write(data)
self.ui_q.put(("skin_path", cache))
except Exception as e: # noqa: BLE001
self.ui_q.put(("log", T("Скин не найден") + ": " + str(e),
"err"))
def load_file():
path = filedialog.askopenfilename(
parent=d, title=T("Своё фото…"),
filetypes=[("PNG", "*.png"), (T("Все файлы"), "*.*")])
if path:
render(path)
brow = tk.Frame(body, bg=BG)
brow.pack(fill="x", pady=(10, 0))
self._btn(brow, T("Обновить"),
lambda: threading.Thread(target=load_nick,
daemon=True).start()).pack(
side="left")
self._btn(brow, T("Своё фото…"), load_file).pack(side="left",
padx=(6, 0))
self._skin_render = render
def close():
if getattr(self, "_skin_render", None) is render:
self._skin_render = None
d.destroy()
d.protocol("WM_DELETE_WINDOW", close)
self._finish_dialog(d)
threading.Thread(target=load_nick, daemon=True).start()
def _make_shortcut(self, profile=None):
"""Ярлык на рабочем столе: pythonw main.py [--version|--profile].
profile — dict профиля: ярлык создаётся сразу на рабочем столе
без диалога и открывает лаунчер с применением этого профиля.
"""
name = ""
if profile is not None:
name = str(profile.get("name") or "").strip()
safe = re.sub(r'[<>:"/\\|?*]', "", name)[:60].strip() or "profile"
desk = os.path.join(os.path.expanduser("~"), "Desktop")
if not os.path.isdir(desk):
desk = os.path.join(os.path.expanduser("~"), "OneDrive",
"Desktop")
path = os.path.join(desk, f"PixelLauncher {safe}.lnk")
else:
vid = self.var_ver.get().strip() or self._selected_version_id()
safe = re.sub(r"[^\w.\-]+", "_", vid)[:50] or "version"
path = filedialog.asksaveasfilename(
parent=self, title=T("Создать ярлык версии…"),
defaultextension=".lnk",
initialfile=f"PixelLauncher {safe}.lnk",
filetypes=[("Ярлык", "*.lnk")])
if not path:
return
exe = sys.executable
if exe.lower().endswith("python.exe"):
cand = exe[:-len("python.exe")] + "pythonw.exe"
if os.path.isfile(cand):
exe = cand
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
main_py = os.path.join(root, "main.py")
def ps(s): # строка для PowerShell (одинарные кавычки)
return "'" + str(s).replace("'", "''") + "'"
if profile is not None:
qname = name.replace('"', "'")
args = f'"{main_py}" --profile "{qname}"'
else:
args = f'"{main_py}" --version {vid}'
ico = next((c for c in (os.path.join(root, "launcher.ico"),
os.path.join(root, "build", "launcher.ico"))
if os.path.isfile(c)), "")
script = ("$sh = New-Object -ComObject WScript.Shell; "
f"$l = $sh.CreateShortcut({ps(path)}); "
f"$l.TargetPath = {ps(exe)}; "
f"$l.Arguments = {ps(args)}; "
f"$l.WorkingDirectory = {ps(root)}; "
+ (f"$l.IconLocation = {ps(ico)}; " if ico else "")
+ "$l.Save()")
try:
r = subprocess.run(
["powershell", "-NoProfile", "-STA", "-Command", script],
capture_output=True, timeout=25,
creationflags=CREATE_NO_WINDOW)
if r.returncode != 0:
raise RuntimeError(
r.stderr.decode(errors="replace")[:200] or "rc!=0")
if profile is not None and os.path.isfile(path):
# выделяем ярлык в проводнике — сразу видно результат
subprocess.run(["explorer", "/select,", path],
capture_output=True, timeout=10,
creationflags=CREATE_NO_WINDOW)
self._log(T("Ярлык создан: ") + path, "ok")
except Exception as e: # noqa: BLE001
self._log(T("Не удалось создать ярлык") + ": " + str(e), "err")
def _set_autostart(self, on):
"""Ярлык в папке автозагрузки Windows (или его удаление)."""
base = os.environ.get("APPDATA") or os.path.expanduser("~")
path = os.path.join(base, "Microsoft", "Windows", "Start Menu",
"Programs", "Startup", "PixelLauncher.lnk")
try:
if not on:
if os.path.isfile(path):
os.remove(path)
self._log(T("Автозагрузка отключена"), "ok")
return True
exe = sys.executable
if exe.lower().endswith("python.exe"):
cand = exe[:-len("python.exe")] + "pythonw.exe"
if os.path.isfile(cand):
exe = cand
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
main_py = os.path.join(root, "main.py")
ico = next((c for c in (os.path.join(root, "launcher.ico"),
os.path.join(root, "build", "launcher.ico"))
if os.path.isfile(c)), "")
def ps(s):
return "'" + str(s).replace("'", "''") + "'"
script = ("$sh = New-Object -ComObject WScript.Shell; "
f"$l = $sh.CreateShortcut({ps(path)}); "
f"$l.TargetPath = {ps(exe)}; "
f"$l.Arguments = {ps('\"' + main_py + '\"')}; "
f"$l.WorkingDirectory = {ps(root)}; "
+ (f"$l.IconLocation = {ps(ico)}; " if ico else "")
+ "$l.Save()")
r = subprocess.run(
["powershell", "-NoProfile", "-STA", "-Command", script],
capture_output=True, timeout=25, creationflags=CREATE_NO_WINDOW)
if r.returncode != 0:
raise RuntimeError(
r.stderr.decode(errors="replace")[:200] or "rc!=0")
self._log(T("Автозагрузка включена: ") + path, "ok")
return True
except Exception as e: # noqa: BLE001
self._log(T("Не удалось изменить автозагрузку") + ": " + str(e),
"err")
return False
def _export_settings(self):
path = filedialog.asksaveasfilename(
parent=self, title=T("Экспорт настроек…"),
defaultextension=".json",
initialfile="pixellauncher-settings.json",
filetypes=[("JSON", "*.json")])
if not path:
return
try:
self._save_settings()
with open(path, "w", encoding="utf-8") as f:
json.dump(self.settings, f, ensure_ascii=False, indent=2)
self._log(T("Настройки экспортированы: ") + path, "ok")
except OSError as e: # noqa: BLE001
self._log(T("Ошибка: ") + str(e), "err")
def _import_settings(self):
path = filedialog.askopenfilename(
parent=self, title=T("Импорт настроек…"),
filetypes=[("JSON", "*.json"), (T("Все файлы"), "*.*")])
if not path:
return
try:
with open(path, encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, dict):
raise ValueError("не JSON-объект")
except (OSError, ValueError) as e: # noqa: BLE001
self._log(T("Не удалось прочитать файл настроек") + ": "
+ str(e), "err")
return
merged = dict(config.DEFAULTS)
merged.update(data)
config._migrate(merged)
self.settings.clear()
self.settings.update(merged)
config.save_settings(self.settings)
set_language(self.settings.get("lang", "ru"))
set_theme(self.settings.get("theme", "dark"))
set_accent(self.settings.get("accent", GREEN))
http.configure(self.settings.get("proxy"))
stats_mod.set_path(os.path.join(self._game_dir(), ".launcher"))
self._scene = self._pick_scene()
self._night = self._scene == "night"
self._apply_ui_scale()
self._rebuild_ui()
self._reload_versions()
nick = self.settings.get("username", "Player")
threading.Thread(target=self._load_head_bg, args=(nick,),
daemon=True).start()
self._log(T("Настройки импортированы"), "ok")
# ------------------------------------------------------------ трей
def _start_tray(self):
if os.name != "nt" or self._tray is not None:
return
try:
hicon = tray_mod.window_icon(self.winfo_id())
self._tray = tray_mod.Tray(
on_activate=lambda: self.ui_q.put(("tray", "show")),
on_command=lambda name: self.ui_q.put(("tray", name)),
commands=[(T("Играть"), "play"), (T("Журнал"), "journal")])
self._tray.start(tooltip="PixelLauncher", hicon=hicon)
except Exception: # noqa: BLE001 — трей не критичен
self._tray = None
def _tray_event(self, name):
if name == "exit":
self._on_close()
return
try:
self.deiconify()
self.lift()
self.focus_force()
except tk.TclError:
pass
if name == "play":
self._on_play()
elif name == "journal" and not self._journal_open():
self._open_log()
def _tray_notify(self, title, text, warn=False):
if self._tray is not None:
try:
self._tray.notify(str(title), str(text), warn=bool(warn))
except Exception: # noqa: BLE001
pass
# ------------------------------------------------------------ обновление
def _check_update(self, quiet=False):
"""Проверяет /update.json; quiet — показывать окна только если есть
новая версия."""
if self._update_active:
return
self._update_active = True
self._update_quiet = bool(quiet)
if not quiet:
self.lbl_status.configure(text=T("Проверка обновления…"), fg=DIM)
threading.Thread(target=self._update_check_bg, args=(quiet,),
daemon=True).start()
def _update_check_bg(self, quiet):
try:
manifest = selfupdate.check()
error = None
except Exception as e: # noqa: BLE001
manifest, error = None, str(e)
self.ui_q.put(("update_found", manifest, error, quiet))
def _on_update_found(self, manifest, error, quiet):
"""Главный поток: диалог о новой версии, запуск скачивания."""
if error:
self._update_active = False
self._update_quiet = False
if not quiet:
self.lbl_status.configure(text=T("Готово к запуску"), fg=DIM)
messagebox.showerror(
T("Обновление"),
T("Не удалось проверить обновления: ") + error,
parent=self)
return
if not manifest:
self._update_active = False
self._update_quiet = False
if not quiet:
self.lbl_status.configure(text=T("Готово к запуску"), fg=DIM)
messagebox.showinfo(T("Обновление"),
T("У вас последняя версия"),
parent=self)
return
notes = str(manifest.get("notes") or "").strip()
text = T("Новая версия {v} (у вас {c}).\n\n{notes}\n\n"
"Скачать и установить?").format(
v=str(manifest.get("version")), c=LAUNCHER_VERSION,
notes=notes or "—")
if not messagebox.askyesno(T("Обновление"), text, parent=self):
self._update_active = False
self._update_quiet = False
if not quiet:
self.lbl_status.configure(text=T("Готово к запуску"), fg=DIM)
return
self._begin_update(manifest)
def _begin_update(self, manifest):
if self.busy:
self._update_active = False
self._update_quiet = False
messagebox.showinfo(T("Обновление"),
T("Дождитесь окончания текущей операции"),
parent=self)
return
self.busy = True
self._cancel = threading.Event()
self.btn_play.set_state("disabled")
self.btn_cancel.configure(state="normal")
self.btn_cancel.pack()
self.progress.set(0)
self.lbl_status.configure(text=T("Скачивание обновления"), fg=DIM)
threading.Thread(target=self._update_download_bg, args=(manifest,),
daemon=True).start()
def _update_download_bg(self, manifest):
try:
path = selfupdate.download(
manifest,
progress=lambda d, t: self.ui_q.put(("update_progress",
d, t)),
cancel=self._cancel)
self.ui_q.put(("update_ready", path))
except http.Cancelled:
self.ui_q.put(("update_fail", T("Отменено")))
except Exception as e: # noqa: BLE001
self.ui_q.put(("update_fail", str(e)))
def _update_reset(self):
"""Снимает состояние занятости обновления."""
self._update_active = False
self._update_quiet = False
self.busy = False
self._cancel = None
self.btn_play.set_state("normal")
try:
self.btn_cancel.configure(state="normal")
self.btn_cancel.pack_forget()
except tk.TclError:
pass
def _on_update_ready(self, path):
self._update_reset()
try:
selfupdate.run_installer(path)
except Exception as e: # noqa: BLE001
messagebox.showerror(
T("Обновление"),
T("Не удалось запустить установщик: ") + str(e),
parent=self)
return
self._log(T("Запускается установщик обновления…"))
self.lbl_status.configure(
text=T("Запускается установщик обновления…"), fg=DIM)
# установщик заменит файлы лаунчера — выходим, пока файлы не заняты
self.after(800, self._on_close)
def _on_update_fail(self, text):
self._update_reset()
self.progress.set(0)
self.lbl_status.configure(text=T("Готово к запуску"), fg=DIM)
self._log(T("Обновление не установлено: ") + text, "warn")
if text != T("Отменено"):
messagebox.showerror(T("Обновление"), text, parent=self)
def _open_about(self):
d = self._dialog(T("О программе"), width=360)
body = tk.Frame(d, bg=BG)
body.pack(fill="both", expand=True, padx=16, pady=12)
tk.Label(body, text=f"PixelLauncher {LAUNCHER_VERSION}", bg=BG,
fg=TEXT, font=("Segoe UI", 12, "bold")).pack(anchor="w")
txt = T("Компактный оффлайн-лаунчер Minecraft\nв стиле LiteLauncher.\n\n"
"• Все версии из манифеста Mojang\n"
"• Автодокачка файлов с проверкой SHA-1\n"
"• Автоподбор Java, копии миров\n"
"• Без сторонних библиотек")
tk.Label(body, text=txt, bg=BG, fg=DIM, font=("Segoe UI", 9),
justify="left").pack(anchor="w", pady=(6, 0))
links = tk.Frame(body, bg=BG)
links.pack(anchor="w", pady=(10, 0))
for name, url in (("minecraft.net", "https://www.minecraft.net"),
(T("Манифест Mojang"),
"https://launchermeta.mojang.com/mc/game/"
"version_manifest_v2.json")):
l = tk.Label(links, text=name, bg=BG, fg=OK, cursor="hand2",
font=("Segoe UI", 9, "underline"))
l.pack(side="left", padx=(0, 12))
l.bind("<Button-1>", lambda e, u=url: webbrowser.open(u))
btns = tk.Frame(body, bg=BG)
btns.pack(fill="x", pady=(14, 0))
self._btn(btns, T("Проверить обновление…"),
lambda: (d.destroy(), self._check_update())
).pack(side="left")
self._btn(btns, T("Закрыть"), d.destroy, primary=True).pack(side="right")
self._finish_dialog(d)
def _pick_java_file(self, var):
path = filedialog.askopenfilename(
parent=self, title=T("Выберите java.exe"),
filetypes=[(T("Java"), "java.exe java"), (T("Все файлы"), "*.*")])
if path:
var.set(path)
def _pick_dir(self, var):
path = filedialog.askdirectory(parent=self, title=T("Папка игры"))
if path:
var.set(path)
# ------------------------------------------------------------ аккаунты
def _ask_text(self, title, label, initial=""):
"""Модальный ввод строки. Возвращает текст или None (отмена)."""
d = self._dialog(title, width=340)
body = tk.Frame(d, bg=BG)
body.pack(fill="both", expand=True, padx=14, pady=10)
tk.Label(body, text=label, bg=BG, fg=DIM, anchor="w",
font=("Segoe UI", 9)).pack(anchor="w")
var = tk.StringVar(value=initial)
e = tk.Entry(body, textvariable=var, font=("Segoe UI", 10),
bg=INPUT, fg=TEXT, insertbackground=TEXT, relief="solid",
bd=1, highlightbackground=INPUT_BD, highlightthickness=1)
e.pack(fill="x", ipady=2, pady=(6, 0))
e.focus_set()
result = [None]
def ok():
v = var.get().strip()
if not re.fullmatch(r"[A-Za-z0-9_]{3,16}", v):
messagebox.showwarning(
"PixelLauncher",
T("Ник: 3-16 символов, латиница, цифры и _"), parent=d)
return
result[0] = v
d.destroy()
btns = tk.Frame(body, bg=BG)
btns.pack(fill="x", pady=(12, 0))
self._btn(btns, T("Сохранить"), ok, primary=True).pack(side="right")
self._btn(btns, T("Отмена"), d.destroy).pack(side="right", padx=(0, 6))
d.bind("<Return>", lambda e: ok())
self._finish_dialog(d)
self.wait_window(d)
return result[0]
def _switch_account(self, nick):
"""Делает ник активным аккаунтом и сохраняет настройки."""
self.settings["active_account"] = nick
self.settings["username"] = nick
config.save_settings(self.settings)
self._emit("account_switched", username=str(nick))
self.var_nick.set(nick)
self.lbl_nick.configure(text=nick)
self._log(T("Аккаунт переключён: ") + nick, "ok")
threading.Thread(target=self._load_head_bg, args=(nick,),
daemon=True).start()
def _open_accounts(self):
d = self._dialog(T("Аккаунты"), width=380)
body = tk.Frame(d, bg=BG)
body.pack(fill="both", expand=True, padx=14, pady=10)
lb = tk.Listbox(body, height=8, bg=INPUT, fg=TEXT, selectbackground=GREEN,
selectforeground="#0d1a0d", relief="solid", bd=1,
highlightthickness=0, font=("Consolas", 10),
exportselection=False)
lb.pack(fill="both", expand=True)
def refresh():
lb.delete(0, "end")
active = self.settings.get("active_account")
for a in self.settings.get("accounts") or []:
lb.insert("end", ("● " if a == active else " ") + a)
if lb.size():
lb.selection_set(0)
refresh()
def selected():
sel = lb.curselection()
accs = self.settings.get("accounts") or []
if not sel or sel[0] >= len(accs):
return None
return accs[sel[0]]
def switch(_=None):
nick = selected()
if nick:
self._switch_account(nick)
d.destroy()
def add():
nick = self._ask_text(T("Добавить аккаунт…"), T("Ник:"))
if not nick:
return
accs = self.settings.get("accounts") or []
if nick not in accs:
accs.append(nick)
self.settings["accounts"] = accs
config.save_settings(self.settings)
refresh()
def remove():
nick = selected()
if not nick:
return
accs = self.settings.get("accounts") or []
if len(accs) <= 1:
messagebox.showwarning(
"PixelLauncher",
T("Нельзя удалить последний аккаунт"), parent=d)
return
accs.remove(nick)
self.settings["accounts"] = accs
if self.settings.get("active_account") == nick:
self._switch_account(accs[0])
config.save_settings(self.settings)
refresh()
btns = tk.Frame(body, bg=BG)
btns.pack(fill="x", pady=(10, 0))
self._btn(btns, T("Выбрать"), switch, primary=True).pack(side="right")
self._btn(btns, T("Удалить"), remove).pack(side="right", padx=(0, 6))
self._btn(btns, T("Добавить аккаунт…"), add).pack(side="left")
lb.bind("<Double-Button-1>", switch)
self._finish_dialog(d)
# ------------------------------------------------------------ Fabric/Quilt
def _open_fabric(self):
if self.busy:
return
d = self._dialog(T("Установка лоадера"), width=500)
body = tk.Frame(d, bg=BG)
body.pack(fill="both", expand=True, padx=14, pady=10)
var_loader = tk.StringVar(value="fabric")
row0 = tk.Frame(body, bg=BG)
row0.pack(fill="x", pady=(0, 6))
tk.Label(row0, text=T("Лоадер:"), bg=BG, fg=DIM, width=14, anchor="w",
font=("Segoe UI", 9)).pack(side="left")
for code, name in (("fabric", T("Fabric")), ("quilt", T("Quilt")),
("forge", T("Forge")), ("neoforge", T("NeoForge"))):
tk.Radiobutton(row0, text=name, variable=var_loader, value=code,
command=lambda: load_list(), bg=BG, fg=TEXT,
selectcolor=INPUT, activebackground=BG,
activeforeground=TEXT, highlightthickness=0,
font=("Segoe UI", 9)).pack(side="left", padx=(0, 6))
tk.Label(body, text=T("Игровая версия:"), bg=BG, fg=DIM, anchor="w",
font=("Segoe UI", 9)).pack(anchor="w", pady=(4, 2))
lb = tk.Listbox(body, height=9, bg=INPUT, fg=TEXT, selectbackground=GREEN,
selectforeground="#0d1a0d", relief="solid", bd=1,
highlightthickness=0, font=("Consolas", 9),
exportselection=False)
lb.pack(fill="both", expand=True)
row1 = tk.Frame(body, bg=BG)
row1.pack(fill="x", pady=(8, 0))
tk.Label(row1, text=T("Версия лоадера:"), bg=BG, fg=DIM, width=14,
anchor="w", font=("Segoe UI", 9)).pack(side="left")
combo = ttk.Combobox(row1, state="readonly", width=24,
font=("Segoe UI", 9))
combo.pack(side="left")
status = tk.Label(body, text="", bg=BG, fg=FAINT, anchor="w",
font=("Segoe UI", 8))
status.pack(fill="x", pady=(6, 0))
note = tk.Label(body, text="", bg=BG, fg=FAINT,
anchor="w", font=("Segoe UI", 8))
note.pack(anchor="w", pady=(2, 0))
q = queue.Queue()
rows = []
gen = [0] # номер запроса версий лоадера (защита от гонки)
FML = ("forge", "neoforge")
def set_note():
if var_loader.get() in FML:
note.configure(text=T("Инсталлятор сам скачает библиотеки и "
"профиль — может занять несколько "
"минут."))
else:
note.configure(text=T("Ставится только JSON профиля — файлы "
"докачаются при запуске."))
def ask_loads(gv):
"""Версии Forge/NeoForge для выбранной игры."""
gen[0] += 1
my, loader = gen[0], var_loader.get()
def bg():
try:
q.put(("loads", loader, gv, my,
forge_mod.loader_versions(loader, gv)))
except Exception as e: # noqa: BLE001
q.put(("loads", loader, gv, my, []))
q.put(("list_err", loader, str(e)))
threading.Thread(target=bg, daemon=True).start()
def on_mc(_e=None):
if var_loader.get() not in FML:
return
sel = lb.curselection()
if not sel or sel[0] >= len(rows):
return
gv = rows[sel[0]]
combo["values"] = []
combo.set("")
status.configure(text=T("Загрузка списка версий лоадера..."),
fg=FAINT)
ask_loads(gv)
lb.bind("<<ListboxSelect>>", on_mc)
def load_list():
loader = var_loader.get()
rows.clear()
lb.delete(0, "end")
combo["values"] = []
combo.set("")
set_note()
status.configure(text=T("Загрузка списка версий лоадера..."), fg=FAINT)
def bg():
try:
if loader in FML:
games = forge_mod.game_versions(loader)
q.put(("list", loader, games, [], True))
else:
games = mods_mod.game_versions(loader)
loads = mods_mod.loader_versions(loader)
q.put(("list", loader, games, loads, False))
except Exception as e: # noqa: BLE001
q.put(("list_err", loader, str(e)))
threading.Thread(target=bg, daemon=True).start()
def poll():
try:
while True:
msg = q.get_nowait()
cur = var_loader.get()
if msg[0] == "list":
_, loader, games, loads, wait_mc = msg
if loader != cur:
continue
lb.delete(0, "end")
rows.clear()
for gid, stable in games:
rows.append(gid)
lb.insert("end", gid + ("" if stable else
" " + T("[снапшот]")))
if rows:
lb.selection_set(0)
if wait_mc:
combo["values"] = []
status.configure(text="", fg=FAINT)
on_mc()
else:
combo["values"] = loads
if loads:
combo.current(0)
status.configure(text="", fg=FAINT)
elif msg[0] == "list_err":
if msg[1] != cur:
continue
status.configure(
text=T("Не удалось получить список версий "
"лоадера: ") + msg[2], fg=ERR)
elif msg[0] == "loads":
_, loader, gv, my, loads = msg
if loader != cur or my != gen[0]:
continue
sel = lb.curselection()
if not sel or sel[0] >= len(rows) or rows[sel[0]] != gv:
continue
combo["values"] = loads
if loads:
combo.current(0)
status.configure(text="", fg=FAINT)
else:
status.configure(
text=T("Нет версий лоадера для этой игры"),
fg=ERR)
elif msg[0] == "log":
status.configure(text=msg[1], fg=FAINT)
elif msg[0] == "installed":
vid = msg[1]
if vid not in self.versions:
pos = len([x for x in self.versions
if x.startswith("latest-")])
self.versions.insert(pos, vid)
self.ver_meta[vid] = ("local", "")
self._pick_version(vid)
self._log(T("Установлено: ") + vid, "ok")
d.destroy()
return
elif msg[0] == "install_err":
status.configure(text=T("Ошибка: ") + msg[1], fg=ERR)
btn_install.configure(state="normal")
except queue.Empty:
pass
if d.winfo_exists():
d.after(120, poll)
def install():
sel = lb.curselection()
if not sel or sel[0] >= len(rows):
return
gv = rows[sel[0]]
lv = combo.get().strip()
loader = var_loader.get()
gd = self._game_dir()
if loader in FML and not lv:
status.configure(text=T("Выберите версию лоадера"), fg=ERR)
return
btn_install.configure(state="disabled")
status.configure(text=T("Установка..."), fg=FAINT)
def bg():
try:
if loader in FML:
vid = forge_mod.install(
gd, loader, gv, lv or None,
log=lambda m: q.put(("log", m)))
else:
vid = mods_mod.install(gd, loader, gv, lv or None)
q.put(("installed", vid))
except Exception as e: # noqa: BLE001
q.put(("install_err", str(e)))
threading.Thread(target=bg, daemon=True).start()
btns = tk.Frame(body, bg=BG)
btns.pack(fill="x", pady=(10, 0))
btn_install = self._btn(btns, T("Установить"), install, primary=True)
btn_install.pack(side="right")
self._btn(btns, T("Закрыть"), d.destroy).pack(side="right", padx=(0, 6))
d.after(100, poll)
load_list()
self._finish_dialog(d)
return d
# ------------------------------------------------------------ очистка
def _open_cleanup(self):
if self.busy:
return
d = self._dialog(T("Очистка версий"), width=540)
body = tk.Frame(d, bg=BG)
body.pack(fill="both", expand=True, padx=14, pady=10)
lb = tk.Listbox(body, height=13, bg=INPUT, fg=TEXT, selectbackground=GREEN,
selectforeground="#0d1a0d", relief="solid", bd=1,
highlightthickness=0, font=("Consolas", 9),
exportselection=False)
lb.pack(fill="both", expand=True)
info = tk.Label(body, text=T("Считаю размеры..."), bg=BG, fg=FAINT,
anchor="w", font=("Segoe UI", 8))
info.pack(fill="x", pady=(6, 0))
q = queue.Queue()
rows = []
def scan():
def bg():
found = []
vroot = os.path.join(self._game_dir(), "versions")
try:
names = sorted(os.listdir(vroot))
except OSError:
names = []
for name in names:
path = os.path.join(vroot, name)
if not os.path.isdir(path):
continue
size = 0
for root, _, files in os.walk(path):
for f in files:
try:
size += os.path.getsize(os.path.join(root, f))
except OSError:
pass
found.append((name, size))
found.sort(key=lambda r: -r[1])
q.put(("scan", found))
threading.Thread(target=bg, daemon=True).start()
def poll():
try:
while True:
msg = q.get_nowait()
if msg[0] == "scan":
rows[:] = msg[1]
lb.delete(0, "end")
for vid, size in rows:
lb.insert("end", "%-32s %8.1f %s" %
(vid, size / 1048576, T("МБ")))
total = sum(s for _, s in rows)
info.configure(text="%d · %.0f %s" %
(len(rows), total / 1048576, T("МБ")))
elif msg[0] == "assets_done":
count, freed = msg[1], msg[2]
info.configure(
text=T("Удалено неиспользуемых ассетов: ") +
"%d · %.1f %s" % (count, freed / 1048576, T("МБ")),
fg=OK)
self._log(
T("Удалено неиспользуемых ассетов: ") +
"%.1f %s" % (freed / 1048576, T("МБ")), "ok")
btn_assets.configure(state="normal")
except queue.Empty:
pass
if d.winfo_exists():
d.after(150, poll)
def remove():
sel = lb.curselection()
if not sel or sel[0] >= len(rows):
return
vid = rows[sel[0]][0]
if vid == self._selected_version_id():
messagebox.showwarning(
"PixelLauncher",
T("Версия используется как выбранная — смените её "
"в списке."), parent=d)
return
if not messagebox.askyesno(
"PixelLauncher",
T("Удалить версию {v}?").format(v=vid), parent=d):
return
shutil.rmtree(os.path.join(self._game_dir(), "versions", vid),
ignore_errors=True)
self.versions = [v for v in self.versions if v != vid]
del rows[sel[0]]
lb.delete(sel[0])
self._log(T("Версия удалена: ") + vid, "ok")
def clean_assets():
btn_assets.configure(state="disabled")
info.configure(text=T("Поиск неиспользуемых ассетов..."))
def bg():
assets = os.path.join(self._game_dir(), "assets")
used = set()
idx_dir = os.path.join(assets, "indexes")
try:
names = os.listdir(idx_dir)
except OSError:
names = []
for name in names:
if not name.endswith(".json"):
continue
try:
with open(os.path.join(idx_dir, name),
encoding="utf-8") as f:
data = json.load(f)
for obj in (data.get("objects") or {}).values():
h = obj.get("hash")
if h:
used.add(h)
except Exception: # noqa: BLE001
continue
freed = count = 0
obj_dir = os.path.join(assets, "objects")
try:
subs = os.listdir(obj_dir)
except OSError:
subs = []
for sub in subs:
sp = os.path.join(obj_dir, sub)
if not os.path.isdir(sp):
continue
for f in os.listdir(sp):
if f in used:
continue
fp = os.path.join(sp, f)
try:
freed += os.path.getsize(fp)
os.remove(fp)
count += 1
except OSError:
pass
q.put(("assets_done", count, freed))
threading.Thread(target=bg, daemon=True).start()
btns = tk.Frame(body, bg=BG)
btns.pack(fill="x", pady=(10, 0))
self._btn(btns, T("Закрыть"), d.destroy, primary=True).pack(side="right")
btn_assets = self._btn(btns, T("Удалить неиспользуемые ассеты"),
clean_assets)
btn_assets.pack(side="right", padx=(0, 6))
self._btn(btns, T("Удалить выбранную версию"), remove).pack(side="left")
d.after(100, poll)
scan()
self._finish_dialog(d)
# ------------------------------------------------------------ лог/статус
def _append_log(self, msg, tag=None):
"""Добавляет строку в журнал (и в открытое окно журнала)."""
self._log_lines.append((time.strftime("%H:%M:%S"), msg, tag))
if len(self._log_lines) > 800:
self._log_lines = self._log_lines[-800:]
txt = self._log_txt_widget
if (txt is not None and txt.winfo_exists()
and self._log_view_mode == "session"):
txt.configure(state="normal")
txt.insert("end", time.strftime("%H:%M:%S") + " ", "ts")
txt.insert("end", msg + "\n", tag or ())
txt.see("end")
txt.configure(state="disabled")
def _log(self, msg, tag=None):
color = {"ok": OK, "err": ERR, "warn": WARN}.get(tag, DIM)
self.lbl_status.configure(text=msg, fg=color)
self._append_log(msg, tag)
def _on_game_exit(self, code, path, vid=None, secs=0):
"""Реакция на завершение игры: код != 0 — показываем хвост лога."""
self._game_proc = None
self._emit("game_exit",
profile_name=str(self.settings.get("active_profile")
or ""),
crashed=bool(code), exit_code=code)
ok = not code
if vid:
stats_mod.record_finish(vid, secs, ok, code)
self.ui_q.put(("tray_notify",
T("Игра завершилась") if ok
else T("Игра аварийно завершилась"),
vid or "", not ok))
if ok:
self._log(T("Игра завершилась"), "warn")
return
self._log(T("Игра аварийно завершилась (код {c}). Хвост лога:").format(
c=code), "err")
tail = []
try:
with open(path, encoding="utf-8", errors="replace") as f:
tail = [ln.rstrip("\n") for ln in f.readlines()[-30:]]
except OSError:
pass
for ln in tail:
if ln.strip():
self._append_log(ln, "err")
if path:
self._append_log(T("Полный лог: ") + path, None)
# --- разбор причины вылета
res = crash_mod.analyze(path=path)
if res["kind"] == "oom":
self._append_log(T("Причина: ") + res["exc"], "err")
self._append_log(T("Похоже, не хватило ОЗУ. Увеличьте её в "
"профиле или выберите пресет."), "warn")
elif res["kind"] == "java":
self._append_log(T("Проблема с Java: ") + res["exc"], "err")
elif res["kind"] == "crash" and res["exc"]:
self._append_log(T("Причина: ") + res["exc"]
+ ((": " + res["msg"]) if res["msg"] else ""),
"err")
culprit = res.get("culprit")
gd = self._game_dir()
if culprit and culprit.lower().endswith(".jar") and os.path.isfile(
os.path.join(mods_mod.mods_dir(gd), culprit)):
if messagebox.askyesno(
T("Игра аварийно завершилась"),
T("Вероятная причина — мод {m}. Отключить его?").format(
m=culprit)):
if mods_mod.set_enabled(gd, culprit, False):
self._log(T("Мод отключён: ") + culprit, "ok")
if not self._journal_open():
self._open_log()
def _append_game_line(self, line):
"""Строка живого вывода игры — в открытое окно журнала."""
txt = self._log_txt_widget
if (txt is None or not txt.winfo_exists()
or self._log_view_mode != "session"):
return
txt.configure(state="normal")
txt.insert("end", line + "\n", "game")
txt.see("end")
try:
n = int(float(txt.index("end-1c").split(".")[0]))
if n > 3000:
txt.delete("1.0", "800.0")
except (ValueError, tk.TclError):
pass
txt.configure(state="disabled")
# ------------------------------------------------------------ данные
def _load_settings_to_ui(self):
self.var_nick = tk.StringVar(value=self.settings.get("username", "Player"))
self.var_ver = tk.StringVar(value=self.settings.get("version", "latest-release"))
self.lbl_nick.configure(text=self.var_nick.get())
def _save_settings(self):
self.settings.update({
"username": self.var_nick.get().strip(),
"version": self.var_ver.get().strip(),
})
config.save_settings(self.settings)
def _game_dir(self):
return self.settings.get("game_dir", config.DEFAULT_GAME_DIR)
def _ram_max(self):
try:
if os.name == "nt":
import ctypes
class MEMORYSTATUSEX(ctypes.Structure):
_fields_ = [
("dwLength", ctypes.c_ulong), ("dwMemoryLoad", ctypes.c_ulong),
("ullTotalPhys", ctypes.c_ulonglong),
("ullAvailPhys", ctypes.c_ulonglong),
("ullTotalPageFile", ctypes.c_ulonglong),
("ullAvailPageFile", ctypes.c_ulonglong),
("ullTotalVirtual", ctypes.c_ulonglong),
("ullAvailVirtual", ctypes.c_ulonglong),
("ullAvailExtendedVirtual", ctypes.c_ulonglong),
]
m = MEMORYSTATUSEX()
m.dwLength = ctypes.sizeof(MEMORYSTATUSEX)
ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(m))
return max(4096, min(32768, int(m.ullTotalPhys // (1024 * 1024))))
except Exception:
pass
return 8192
def _open_explorer(self, d):
os.makedirs(d, exist_ok=True)
if os.name == "nt":
os.startfile(d) # noqa: S606
else:
subprocess.Popen(["xdg-open", d], creationflags=CREATE_NO_WINDOW)
# ------------------------------------------------------------ галерея
def _open_gallery(self):
"""Галерея скриншотов: сетка превью, просмотр, удаление."""
d = self._dialog(T("Галерея скриншотов"), width=640)
body = tk.Frame(d, bg=BG)
body.pack(fill="both", expand=True, padx=12, pady=10)
shot_dir = os.path.join(self._game_dir(), "screenshots")
cols, tw, th = 4, 136, 84
cv = tk.Canvas(body, bg=BG, highlightthickness=0)
sb = tk.Scrollbar(body, orient="vertical", command=cv.yview)
cv.configure(yscrollcommand=sb.set)
sb.pack(side="right", fill="y")
cv.pack(side="left", fill="both", expand=True)
grid = tk.Frame(cv, bg=BG)
cv_win = cv.create_window((0, 0), window=grid, anchor="nw")
grid.bind("<Configure>",
lambda e: cv.configure(scrollregion=cv.bbox("all")))
cv.bind("<Configure>",
lambda e: cv.itemconfigure(cv_win, width=e.width))
cv.bind("<MouseWheel>",
lambda e: cv.yview_scroll(-1 if e.delta > 0 else 1, "units"))
def list_shots():
try:
fs = [os.path.join(shot_dir, fn) for fn in os.listdir(shot_dir)
if fn.lower().endswith(".png")]
fs = [p for p in fs if os.path.isfile(p)]
fs.sort(key=lambda p: os.path.getmtime(p), reverse=True)
return fs[:48]
except OSError:
return []
photos = [] # ссылки, чтобы Tk не освободил картинки
def load_thumb(cnv, path):
if not cnv.winfo_exists():
return
try:
img = tk.PhotoImage(file=path)
k = max(1, (img.width() + tw - 1) // tw,
(img.height() + th - 1) // th)
small = img.subsample(k, k)
photos.append(small)
cnv.create_image(tw // 2, th // 2, image=small)
except tk.TclError:
cnv.create_text(tw // 2, th // 2, text="?", fill=FAINT,
font=("Segoe UI", 14))
def open_view(path):
if not os.path.isfile(path):
return
v = tk.Toplevel(self)
v.configure(bg=BG)
v.title(os.path.basename(path))
vw, vh = 960, 620
v.geometry(f"{vw}x{vh}+"
f"{self.winfo_rootx() + (self.winfo_width() - vw) // 2}"
f"+{self.winfo_rooty() + (self.winfo_height() - vh) // 2}")
_dark_titlebar(v)
holder = {"img": None}
pic = tk.Label(v, bg="#0c0c0c")
pic.pack(fill="both", expand=True, padx=10, pady=10)
try:
img = tk.PhotoImage(file=path)
k = max(1, (img.width() + vw - 61) // (vw - 40),
(img.height() + vh - 121) // (vh - 100))
if k > 1:
img = img.subsample(k, k)
holder["img"] = img
pic.configure(image=img)
except tk.TclError as e: # noqa: BLE001
pic.configure(text=T("Не удалось открыть скриншот") + ": "
+ str(e), fg=ERR)
btns = tk.Frame(v, bg=BG)
btns.pack(fill="x", padx=10, pady=(0, 10))
def open_file():
try:
os.startfile(path) # noqa: S606
except OSError as e: # noqa: BLE001
self._log(T("Не удалось открыть скриншот") + ": "
+ str(e), "err")
def remove():
if not messagebox.askyesno(
"PixelLauncher",
T("Удалить скриншот {p}?").format(
p=os.path.basename(path)), parent=v):
return
try:
os.remove(path)
self._log(T("Скриншот удалён: ")
+ os.path.basename(path), "ok")
except OSError as e: # noqa: BLE001
self._log(T("Не удалось удалить скриншот") + ": "
+ str(e), "err")
v.destroy()
refresh()
self._btn(btns, T("Открыть файл"), open_file).pack(side="left")
self._btn(btns, T("Удалить"), remove).pack(side="left",
padx=(6, 0))
self._btn(btns, T("Закрыть"), v.destroy).pack(side="right")
def cell(parent, path):
wrap = tk.Frame(parent, bg=INPUT, highlightthickness=1,
highlightbackground=BAR_BD, cursor="hand2")
wrap.pack(side="left", padx=5, pady=5)
cnv = tk.Canvas(wrap, width=tw, height=th, bg="#0c0c0c",
highlightthickness=0)
cnv.pack()
tk.Label(wrap, text=os.path.basename(path), bg=INPUT, fg=DIM,
width=22, anchor="center",
font=("Segoe UI", 7)).pack()
for wgt in (wrap, cnv):
wgt.bind("<Button-1>", lambda e, p=path: open_view(p))
return cnv
def refresh():
for w in grid.winfo_children():
w.destroy()
files = list_shots()
if not files:
tk.Label(grid, text=T("Скриншотов пока нет"), bg=BG, fg=DIM,
font=("Segoe UI", 10)).pack(pady=(24, 0))
self._btn(grid, T("Открыть папку"),
lambda: self._open_explorer(shot_dir)).pack(
pady=(10, 0))
return
row = None
for i, p in enumerate(files):
if i % cols == 0:
row = tk.Frame(grid, bg=BG)
row.pack(fill="x")
cnv = cell(row, p)
# превью грузим по одному — окно не подвисает
grid.after(40 + i * 35,
lambda c=cnv, pp=p: load_thumb(c, pp))
refresh()
self._finish_dialog(d)
return d
# ------------------------------------------------------ перетаскивание
def _enable_dnd(self):
"""Приём файлов, брошенных в окно: .mrpack, .jar, .py."""
if os.name != "nt":
return
try:
WM_DROPFILES = 0x0233
GWLP_WNDPROC = -4
WNDPROC = ctypes.WINFUNCTYPE(
ctypes.c_ssize_t, wintypes.HWND, wintypes.UINT,
wintypes.WPARAM, wintypes.LPARAM)
user32 = ctypes.windll.user32
shell32 = ctypes.windll.shell32
user32.SetWindowLongPtrW.argtypes = (
wintypes.HWND, ctypes.c_int, ctypes.c_void_p)
user32.SetWindowLongPtrW.restype = ctypes.c_ssize_t
user32.CallWindowProcW.argtypes = (
ctypes.c_void_p, wintypes.HWND, wintypes.UINT,
wintypes.WPARAM, wintypes.LPARAM)
user32.CallWindowProcW.restype = ctypes.c_ssize_t
shell32.DragQueryFileW.argtypes = (
ctypes.c_void_p, wintypes.UINT, wintypes.LPCWSTR,
wintypes.UINT)
shell32.DragQueryFileW.restype = wintypes.UINT
shell32.DragFinish.argtypes = (ctypes.c_void_p,)
shell32.DragAcceptFiles.argtypes = (wintypes.HWND, wintypes.BOOL)
hwnd = wintypes.HWND(self.winfo_id())
def take_paths(hglobal):
out = []
try:
n = shell32.DragQueryFileW(hglobal, 0xffffffff, None, 0)
buf = ctypes.create_unicode_buffer(32768)
for i in range(n):
shell32.DragQueryFileW(hglobal, i, buf, 32768)
out.append(buf.value)
finally:
shell32.DragFinish(hglobal)
return out
def wnd_proc(h, msg, w, l):
if msg == WM_DROPFILES:
# максимум осторожности: только ctypes, без вызовов
# tkinter — иначе CPython падает (GIL) внутри цикла tcl
try:
files = take_paths(w)
if files:
self._dnd_queue.append(files)
except Exception: # noqa: BLE001
pass
return 0
return user32.CallWindowProcW(self._dnd_old, h, msg, w, l)
self._dnd_proc = WNDPROC(wnd_proc)
self._dnd_queue = []
self._dnd_old = user32.SetWindowLongPtrW(
hwnd, GWLP_WNDPROC, self._dnd_proc)
shell32.DragAcceptFiles(hwnd, True)
def poll_dnd():
if self._dnd_queue:
files = self._dnd_queue.pop(0)
self._on_drop(files)
self.after(200, poll_dnd)
self.after(200, poll_dnd)
except Exception as e: # noqa: BLE001
try:
self._log("DnD: " + str(e), "warn")
except Exception: # noqa: BLE001
pass
def _on_drop(self, files):
"""Что делать с файлами, брошенными в окно."""
for p in files:
name = os.path.basename(p)
low = p.lower()
if low.endswith(".mrpack"):
self._log(T("Импорт модпака: ") + name)
self._import_pack(p)
elif low.endswith(".jar"):
self._add_mod_file(p)
elif low.endswith(".py"):
self._add_plugin_file(p)
else:
self._log(T("Файл не подходит для лаунчера: ") + name,
"warn")
def _add_mod_file(self, path):
try:
shutil.copy2(path, os.path.join(
mods_mod.mods_dir(self._game_dir()), os.path.basename(path)))
self._log(T("Мод добавлен: ") + os.path.basename(path), "ok")
except OSError as e: # noqa: BLE001
self._log(T("Не удалось добавить мод") + ": " + str(e), "err")
def _add_plugin_file(self, path):
try:
shutil.copy2(path, os.path.join(plugins_mod.PLUGIN_DIR,
os.path.basename(path)))
self._log(T("Плагин добавлен: ") + os.path.basename(path), "ok")
plugins_mod.scan()
except OSError as e: # noqa: BLE001
self._log(T("Не удалось добавить плагин") + ": " + str(e), "err")
# ------------------------------------------------------------ версии
def _load_versions_bg(self):
try:
gd = self._game_dir()
m = manifest_mod.get_manifest(gd, net=True)
latest = m.get("latest", {})
ids = []
if latest.get("release"):
ids.append("latest-release (" + latest["release"] + ")")
if latest.get("snapshot"):
ids.append("latest-snapshot (" + latest["snapshot"] + ")")
releases, snaps, other = [], [], []
for v in m.get("versions", []):
vid, vtype = v.get("id"), v.get("type", "release")
date = (v.get("releaseTime") or "")[:10]
self.ver_meta[vid] = (vtype, date)
(releases if vtype == "release" else
snaps if vtype == "snapshot" else other).append(vid)
# локальные версии (Fabric/Quilt) — сразу после latest-записей
known = set(releases + snaps + other)
extra = [v for v in manifest_mod.local_versions(gd) if v not in known]
for v in extra:
self.ver_meta[v] = ("local", "")
pos = len([x for x in ids if x.startswith("latest-")])
ids[pos:pos] = extra
ids += releases + snaps + other
self.versions = ids
news = [(v.get("id"), v.get("type", "release"),
(v.get("releaseTime") or "")[:10])
for v in m.get("versions", [])[:3]]
self.ui_q.put(("versions", ids, latest, news))
except Exception as e: # noqa: BLE001
self.ui_q.put(("log", T("Не удалось загрузить список версий: ")
+ str(e), "err"))
def _apply_versions(self, ids, latest, news=None):
self._news = news
cur = self.var_ver.get()
if self._force_version:
if self._force_version in ids:
cur = self._force_version
self.var_ver.set(cur)
self._save_settings()
self._force_version = None
if not cur or (cur not in ids and not cur.startswith("latest-")):
self.var_ver.set(ids[0] if ids else "")
self._fill_news(news)
self._update_pill()
self._log(T("Загружено версий: ") + str(len(ids)), "ok")
def _reload_versions(self):
if self.busy:
return
manifest_mod.invalidate() # следующая загрузка пойдёт в сеть
threading.Thread(target=self._load_versions_bg, daemon=True).start()
def _update_pill(self):
vid = self._selected_version_id()
prof = self.settings.get("active_profile") or ""
text = f"Minecraft {vid}"
if prof:
text = f"{prof} · {text}"
self.pill.set_text(text)
if hasattr(self, "quick_ver_chip") and self.quick_ver_chip.winfo_exists():
try:
self.quick_ver_chip.configure(text=self._ver_chip_text())
except tk.TclError:
pass
def _selected_version_id(self):
if hasattr(self, "var_ver") and self.var_ver is not None:
try:
vsel = self.var_ver.get().strip()
except Exception:
vsel = ""
else:
vsel = str(self.settings.get("version", "latest-release") or "")
latest = self._latest or {}
if vsel.startswith("latest-release"):
return latest.get("release", "latest-release")
if vsel.startswith("latest-snapshot"):
return latest.get("snapshot", "latest-snapshot")
return vsel or "…"
def _fill_news(self, news):
"""Заполняет ленту новостей последними версиями из манифеста."""
for w in self.news_rows.winfo_children():
w.destroy()
if not news:
tk.Label(self.news_rows, text=T("загрузка…"), bg=BG, fg=FAINT,
font=("Segoe UI", 8)).pack()
return
for vid, vtype, date in news:
row = tk.Frame(self.news_rows, bg=BG, cursor="hand2")
row.pack(pady=1)
color = {"release": OK, "snapshot": WARN}.get(vtype, FAINT)
cells = [
tk.Label(row, text=vid, bg=BG, fg=TEXT,
font=("Segoe UI", 9, "bold")),
tk.Label(row, text=T(TYPE_RU.get(vtype, vtype)), bg=BG, fg=color,
font=("Segoe UI", 8)),
tk.Label(row, text=date, bg=BG, fg=FAINT,
font=("Segoe UI", 8)),
]
for i, c in enumerate(cells):
c.pack(side="left", padx=(0 if i == 0 else 10, 0))
c.bind("<Button-1>", lambda e, v=vid: self._pick_version(v))
def _pick_version(self, vid):
"""Выбор версии из ленты новостей или диалога."""
if self.busy:
return
self.var_ver.set(vid)
self._save_settings()
self._update_pill()
self._log(T("Версия выбрана: ") + vid, "ok")
# ------------------------------------------------------------ запуск
def _on_play(self):
if self.busy:
return
nick = self.var_nick.get().strip()
if not re.fullmatch(r"[A-Za-z0-9_]{3,16}", nick):
messagebox.showwarning("PixelLauncher",
T("Ник: 3-16 символов, латиница, цифры и _"))
return
self._save_settings()
self._begin_work(nick, do_launch=True)
def _on_download(self):
if self.busy:
return
self._save_settings()
self._begin_work()
def _on_verify(self):
"""Полная проверка SHA-1 всех файлов выбранной версии."""
if self.busy:
return
self._save_settings()
self._begin_work(verify=True)
def _on_cancel(self):
"""Останавливает идущую загрузку."""
if not self.busy or self._cancel is None:
return
self._cancel.set()
self.btn_cancel.configure(state="disabled")
self._log(T("Отмена..."), "warn")
def _auto_ram(self):
"""ОЗУ по умолчанию: половина памяти системы (512-кратно)."""
total = self._ram_max()
return int(max(2048, min(total // 2, 16384)) // 512 * 512)
def _begin_work(self, nick=None, do_launch=False, verify=False):
self.busy = True
self._cancel = threading.Event()
self.btn_play.set_state("disabled")
self.btn_cancel.configure(state="normal")
self.btn_cancel.pack()
self.progress.set(0)
threading.Thread(target=self._work_bg, args=(nick, do_launch, verify),
daemon=True).start()
def _work_bg(self, nick, do_launch, verify=False):
log = lambda m, t=None: self.ui_q.put(("log", m, t)) # noqa: E731
prog = lambda d, t, title="": self.ui_q.put(("progress", d, t, title)) # noqa: E731
cancel = self._cancel
try:
gd = self._game_dir()
os.makedirs(gd, exist_ok=True)
log(T("Получение манифеста версий..."))
manifest_mod.get_manifest(gd, net=True)
vid = self._selected_version_id()
log(T("Версия: ") + vid)
# авто-бэкап мира перед запуском (по настройке)
if do_launch and self.settings.get("auto_backup"):
try:
self._backup_worlds(gd, log)
except Exception as be: # noqa: BLE001
log(T("Ошибка: ") + str(be), "warn")
ver = version_mod.load(gd, vid, net=True)
if not ver.main_class:
raise RuntimeError("Не удалось разобрать JSON версии")
required = max(ver.java_major, 8)
preferred = self.settings.get("java_path", "")
if not preferred:
# уже скачанная нами Java (Adoptium) — приоритетнее прочих
preferred = java_fetch.find_installed(
os.path.join(gd, ".launcher", "runtime"), required)
jpath, jver, ok = java_mod.pick_java(
required, preferred,
cache_path=os.path.join(gd, ".launcher", "java_cache.json"))
if not jpath:
log(T("Java не найдена, скачиваю..."), "warn")
jpath = java_fetch.ensure_java(
required, os.path.join(gd, ".launcher", "runtime"),
progress=lambda d, t: prog(d, t, "Java"),
log=lambda m, t=None: self.ui_q.put(("log", m, t)),
cancel=cancel)
jver = java_mod.java_major(jpath) or required
ok = jver >= required
if ok:
log(T("Java {jver} найдена").format(jver=jver), "ok")
else:
log(T("Нужна Java {r}+, найдена {j}. Возможны проблемы.")
.format(r=required, j=jver), "warn")
if verify:
log(T("Проверка целостности файлов (SHA-1)..."))
else:
log(T("Скачивание файлов игры..."))
errors, natives = downloader.ensure(
ver,
progress=lambda d, t: prog(d, t, T("Файлы")),
asset_progress=lambda d, t: prog(d, t, T("Ассеты")),
log=lambda m: self.ui_q.put(("log", m, None)),
verify=verify,
cancel=cancel,
asset_mirrors=self.settings.get("asset_mirrors") or None,
lib_mirrors=self.settings.get("lib_mirrors") or None)
if errors:
log(T("Ошибок скачивания: ") + str(len(errors)) + " ("
+ "; ".join(errors[:2]) + ")", "err")
if do_launch:
uuid_hex = launch_mod.offline_uuid(nick)
ram = (self._auto_ram() if self.settings.get("auto_ram")
else int(self.settings.get("ram", 2048)))
cmd = ver.build_command(jpath, nick, uuid_hex, ram,
self.settings.get("jvm_args", ""))
# размер окна/полный экран из профиля
try:
ww = int(self.settings.get("win_w") or 0)
wh = int(self.settings.get("win_h") or 0)
except (TypeError, ValueError):
ww = wh = 0
if ww > 0:
cmd += ["--width", str(ww)]
if wh > 0:
cmd += ["--height", str(wh)]
if self.settings.get("fullscreen"):
cmd += ["--fullscreen"]
log_path = os.path.join(gd, ".launcher", "logs",
"game-" + time.strftime("%Y%m%d-%H%M%S")
+ ".log")
def on_line(line):
# не раздуваем очередь, если журнал закрыт и игра шумит
if self.ui_q.qsize() < 3000:
self.ui_q.put(("game_line", line))
def on_exit(code):
self.ui_q.put(("game_exit", code, log_path, vid,
int(time.time() - t0)))
prof = str(self.settings.get("active_profile") or "") or nick
self._emit("before_launch", profile_name=prof,
version=str(vid))
t0 = time.time()
stats_mod.record_start(vid)
proc = launch_mod.launch(
gd, cmd,
log=lambda m: self.ui_q.put(
("log", m, "ok"
if m.startswith(T("Игра запущена, PID ")) else None)),
log_path=log_path, on_line=on_line, on_exit=on_exit)
self._game_proc = proc
if self.settings.get("minimize_to_tray_on_launch"):
self.ui_q.put(("minimize",))
self._emit("after_launch", profile_name=prof,
version=str(vid), pid=proc.pid)
self.ui_q.put(("log", T("Вывод игры пишется в ") + log_path,
None))
self.ui_q.put(("tray_notify", T("Игра запущена"), vid))
else:
if verify and not errors:
log(T("Все файлы на месте, повреждений не найдено."), "ok")
else:
log(T("Версия скачана и готова к запуску."), "ok")
except http.Cancelled:
log(T("Загрузка отменена"), "warn")
except Exception as e: # noqa: BLE001
log(T("Ошибка: ") + str(e), "err")
finally:
self.ui_q.put(("done",))
# ------------------------------------------------------------ бэкап
def _on_backup(self):
if self.busy:
return
self.busy = True
self.btn_play.set_state("disabled")
self.progress.set(0)
threading.Thread(target=self._backup_bg, daemon=True).start()
def _backup_worlds(self, gd, log, progress=None):
"""ZIP-копия папки saves. Возвращает каталог backups."""
import zipfile
saves = os.path.join(gd, "saves")
if not os.path.isdir(saves):
raise RuntimeError(T("Папка миров (saves) не найдена"))
out_dir = os.path.join(gd, "backups")
os.makedirs(out_dir, exist_ok=True)
path = os.path.join(out_dir,
"saves-" + time.strftime("%Y%m%d-%H%M%S") + ".zip")
files = []
for root, _, fnames in os.walk(saves):
for f in fnames:
fp = os.path.join(root, f)
files.append((fp, os.path.relpath(fp, saves)))
log(T("Создание резервной копии: ") + str(len(files))
+ T(" файлов..."))
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as z:
for i, (fp, rel) in enumerate(files, 1):
try:
z.write(fp, rel)
except OSError:
continue
if progress and (i % 10 == 0 or i == len(files)):
progress(i, len(files))
size_mb = os.path.getsize(path) / (1024 * 1024)
log(T("Копия создана: ") + os.path.basename(path) + f" ({size_mb:.1f} "
+ T("МБ") + ")", "ok")
return out_dir
def _backup_bg(self):
log = lambda m, t=None: self.ui_q.put(("log", m, t)) # noqa: E731
try:
out_dir = self._backup_worlds(
self._game_dir(), log,
progress=lambda i, n: self.ui_q.put(
("progress", i, n, T("Копирование"))))
self.ui_q.put(("open_dir", out_dir))
except Exception as e: # noqa: BLE001
log(T("Ошибка: ") + str(e), "err")
finally:
self.ui_q.put(("done",))
def _on_close(self):
if self._close_menu_if_open():
# меню модальное: tk_popup вернётся после unpost,
# и только тогда уничтожаем окно
self._close_after_menu = True
return
if self._tray is not None:
try:
self._tray.stop()
except Exception: # noqa: BLE001
pass
self._tray = None
self._emit("launcher_closing")
protocol_mod.clear_instance()
try:
self._save_settings()
except Exception:
pass
self._cancel_afters(self)
self.destroy()
# ------------------------------------------------------------ poll
def _poll_ui(self):
try:
while True:
msg = self.ui_q.get_nowait()
kind = msg[0]
if kind == "log":
self._log(msg[1], msg[2] if len(msg) > 2 else None)
elif kind == "versions":
self._latest = msg[2]
self._apply_versions(msg[1], msg[2],
msg[3] if len(msg) > 3 else None)
if not getattr(self, "_mod_upd_done", False):
self._mod_upd_done = True
self._startup_mod_updates()
elif kind == "progress":
_, done, total, title = msg
pct = (done / total * 100) if total else 0
self.progress.set(pct)
if title:
self.lbl_status.configure(text=f"{title} {done}/{total}",
fg=DIM)
elif kind == "head":
self._show_head(msg[1], msg[2])
elif kind == "open_dir":
self._open_explorer(msg[1])
elif kind == "minimize":
self._minimize()
elif kind == "game_line":
self._append_game_line(msg[1])
elif kind == "game_exit":
self._on_game_exit(
msg[1], msg[2] if len(msg) > 2 else "",
msg[3] if len(msg) > 3 else None,
msg[4] if len(msg) > 4 else 0)
elif kind == "tray":
self._tray_event(msg[1])
elif kind == "tray_notify":
self._tray_notify(msg[1], msg[2],
msg[3] if len(msg) > 3 else False)
elif kind == "skin_path":
render = getattr(self, "_skin_render", None)
if render:
render(msg[1])
elif kind == "use_version":
self.var_ver.set(msg[1])
self._save_settings()
self._update_pill()
elif kind == "reload_versions":
self._reload_versions()
elif kind == "update_found":
self._on_update_found(msg[1], msg[2], msg[3])
elif kind == "update_progress":
_, done, total = msg
pct = (done / total * 100) if total else 0
self.progress.set(pct)
self.lbl_status.configure(
text=f"{T('Скачивание обновления')} "
f"{pct:.0f}%", fg=DIM)
elif kind == "update_ready":
self._on_update_ready(msg[1])
elif kind == "update_fail":
self._on_update_fail(msg[1])
elif kind == "done":
self.busy = False
self._cancel = None
self.btn_play.set_state("normal")
try:
self.btn_cancel.configure(state="normal")
self.btn_cancel.pack_forget()
except tk.TclError:
pass
self.progress.set(100)
self.after(2500, self._reset_progress)
except queue.Empty:
pass
except tk.TclError:
return # окно уничтожено — не перепланируем
try:
self.after(100, self._poll_ui)
except tk.TclError:
pass
@staticmethod
def _cancel_afters(app):
"""Снимает все отложенные callback'и Tk (чтобы не сыпались bgerror
уже после destroy)."""
try:
for aid in app.tk.splitlist(app.tk.call("after", "info")):
try:
app.tk.call("after", "cancel", aid)
except tk.TclError:
pass
except tk.TclError:
pass
def _reset_progress(self):
if not self.busy:
self.progress.set(0)
self.lbl_status.configure(text=T("Готово к запуску"), fg=DIM)
def run(select_version=None, protocol_url=None, select_profile=None):
if os.name == "nt":
try:
import ctypes
ctypes.windll.shcore.SetProcessDpiAwareness(1)
except Exception:
pass
settings = config.load_settings()
if not settings.get("onboarding_done"):
from . import onboarding
onboarding.run(settings) # первый запуск: язык, ник, ОЗУ
config.save_settings(settings)
if protocol_url and protocol_mod.other_instance_running():
protocol_mod.queue_url(protocol_url) # лаунчер уже открыт — в очередь
return
protocol_mod.mark_instance()
app = App(select_version=select_version, protocol_url=protocol_url,
select_profile=select_profile)
app.mainloop()
protocol_mod.clear_instance()