"""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 # Pillow (если доступна) — сглаженные иконки сборок; без неё — через Tk try: import io as _pil_io from PIL import Image as _PILImage, ImageDraw as _PILDraw from PIL import ImageTk as _PILImageTk except Exception: # noqa: BLE001 _PILImage = _PILDraw = _PILImageTk = _pil_io = None from . import config, crash as crash_mod, downloader, forge as forge_mod, http from . import curseforge as curseforge_mod from . import instances as inst_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, 684 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("", self._enter) self.bind("", self._leave) self.bind("", 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("", lambda e: self._draw()) self.bind("", self._enter) self.bind("", self._leave) self.bind("", self._press) self.bind("", 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("", lambda e: self._draw()) self.bind("", lambda e: (self.configure(bg=HOVER), self._draw())) self.bind("", lambda e: (self.configure(bg=BG), self._draw())) self.bind("", 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("", 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._maximized = False # окно развёрнуто на весь экран self._restore_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")) curseforge_mod.set_api_key(self.settings.get("cf_api_key")) self._scene = self._pick_scene() self._night = self._scene == "night" self._apply_ui_scale() self._build() self.bind("", self._on_win_configure) # мин. высота self._set_icon() self._enable_dnd() self._load_settings_to_ui() self._refresh_instances() _act_inst = self._current_instance() if _act_inst: self._apply_instance(_act_inst) if select_profile: self._apply_profile_at_start(select_profile) self._center() self._maximize() # лаунчер всегда стартует развёрнутым на экран 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("", 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") # Заголовок текущего инстанса (как Prism) self.lbl_inst = tk.Label(left, text="", bg=BAR, fg=FAINT, font=("Segoe UI", 9)) self.lbl_inst.pack(side="left", padx=(10, 0)) 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("", 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") self.btn_max = Icon_label(right, "□", self._toggle_maximize) self.btn_max.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("", self._drag_start) bar.bind("", self._drag_move) # двойной клик по строке — разворот/возврат, как у обычного окна bar.bind("", self._on_title_double) self._bind_double(left) # --- Основной контент: панель инстансов слева + контент справа main = tk.Frame(self, bg=BG) main.pack(fill="both", expand=True, pady=(26, 0)) # Панель инстансов (как Prism: список слева, контент справа) self.inst_panel = tk.Frame(main, bg=PANEL, width=200) self.inst_panel.pack(side="left", fill="y") self.inst_panel.pack_propagate(False) inst_head = tk.Frame(self.inst_panel, bg=PANEL) inst_head.pack(fill="x", padx=8, pady=(8, 4)) tk.Label(inst_head, text=T("Инстансы"), bg=PANEL, fg=FAINT, font=("Segoe UI", 7, "bold")).pack(side="left") Icon_label(inst_head, "+", self._open_new_instance).pack(side="right") inst_wrap = tk.Frame(self.inst_panel, bg=PANEL) inst_wrap.pack(fill="both", expand=True, padx=4) inst_cv = tk.Canvas(inst_wrap, bg=PANEL, highlightthickness=0, bd=0) inst_sb = tk.Scrollbar(inst_wrap, orient="vertical", command=inst_cv.yview) # тёмный скроллбар (не белая полоса). ПОРЯДОК PACK ВАЖЕН: sb первым, # иначе Tk не отдаёт ему места и он вообще не покажется. # width=0 — «спрятан», 17 — виден; переключает _toggle_inst_scroll. inst_sb.configure(troughcolor=PANEL, bg=PILL, activebackground=HOVER, highlightthickness=0, bd=0, width=0) inst_cv.configure(yscrollcommand=self._on_inst_scroll) self._inst_cv, self._inst_sb = inst_cv, inst_sb inst_sb.pack(side="right", fill="y") inst_cv.pack(side="left", fill="both", expand=True) self.inst_rows = tk.Frame(inst_cv, bg=PANEL) inst_win = inst_cv.create_window((0, 0), window=self.inst_rows, anchor="nw") self.inst_rows.bind("", lambda e: inst_cv.configure( scrollregion=inst_cv.bbox("all"))) inst_cv.bind("", lambda e: (inst_cv.itemconfigure(inst_win, width=e.width), self._toggle_inst_scroll())) inst_cv.bind("", lambda e: inst_cv.yview_scroll( -1 if e.delta > 0 else 1, "units")) # Контекстное меню по правой кнопке на инстансе self.inst_menu = tk.Menu(self, tearoff=0, bg=PANEL, fg=TEXT, activebackground=GREEN, activeforeground="#0d1a0d", bd=0, relief="flat", font=("Segoe UI", 9)) # правая панель — контент content = tk.Frame(main, bg=BG) content.pack(side="left", fill="both", expand=True) self.banner = tk.Canvas(content, width=BANNER_W, height=BANNER_H, bg=BG, highlightthickness=0) self.banner.pack(fill="x") self.banner.bind("", self._on_banner_configure) self.btn_play = PlayButton(content, self._on_play) self.btn_play.pack(pady=(26, 0)) self.pill = VersionPill(content, self._open_versions) self.pill.pack(pady=(14, 0)) # --- Лента новостей: последние версии из манифеста Mojang self.news = tk.Frame(content, 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(content, bg=BG) self.quick_bar.pack(fill="x", pady=(10, 0)) self._build_quick_bar(self.quick_bar) self.lbl_status = tk.Label(content, text=T("Готово к запуску"), bg=BG, fg=DIM, font=("Segoe UI", 8)) self.lbl_status.pack(pady=(12, 0)) # Кнопка отмены — видна только пока идёт загрузка self.btn_cancel = tk.Button(content, 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") self.grip = grip 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(self._min_height(), self._grip[3] + e.y_root - self._grip[1]) self.geometry(f"{w}x{h}") grip.bind("", grip_start) grip.bind("", grip_move) # ------------------------------------------------- мин. высота окна def _min_height(self): """Минимальная высота окна: естественный размер контента. Ниже не сжимаем — иначе панель быстрых настроек внизу уходит за край окна и чипы обрезаются. Ограничена рабочей областью экрана, чтобы окно не вылезало за панель задач. """ try: need = self.winfo_reqheight() limit = max(WIN_H, self.winfo_screenheight() - 72) except Exception: # noqa: BLE001 — окно уже уничтожено return WIN_H return min(max(WIN_H, need), limit) def _fit_window_height(self): """Растягивает окно, если оно ниже естественного размера.""" try: if not self.winfo_ismapped(): return need = self._min_height() cur = self.winfo_height() if need > cur + 1: self.geometry("%dx%d" % (self.winfo_width(), need)) except Exception: # noqa: BLE001 — окно уже уничтожено pass def _on_win_configure(self, _event): """Проверяет высоту окна при каждом изменении его размера.""" if _event.widget is not self: return # события детей не трогаем if getattr(self, "_fit_after", None): try: self.after_cancel(self._fit_after) except Exception: # noqa: BLE001 — уже отменён/выполнен pass self._fit_after = self.after(80, self._fit_tick) def _fit_tick(self): self._fit_after = None self._fit_window_height() 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: # — перерисуем через паузу 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("", self._drag_start) w.bind("", self._drag_move) for c in w.winfo_children(): self._make_draggable(c) def _bind_double(self, w): """Двойной клик по строке окна — разворот/возврат.""" w.bind("", self._on_title_double) for c in w.winfo_children(): self._bind_double(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): if self._maximized: # тянем развёрнутое окно — возвращаем прежний размер self._unmaximize_under_cursor(e) self.geometry("+%d+%d" % (e.x_root - self._drag_dx, e.y_root - self._drag_dy)) def _on_title_double(self, _event=None): self._toggle_maximize() # ------------------------------------------------ разворот на экран def _work_area(self): """Рабочая область экрана под окном (без панели задач).""" try: u32 = ctypes.windll.user32 hwnd = u32.GetAncestor(self.winfo_id(), 2) # GA_ROOT MONITOR_DEFAULTTONEAREST = 2 class MONITORINFO(ctypes.Structure): _fields_ = [("cbSize", wintypes.DWORD), ("rcMonitor", wintypes.RECT), ("rcWork", wintypes.RECT), ("dwFlags", wintypes.DWORD)] mi = MONITORINFO() mi.cbSize = ctypes.sizeof(MONITORINFO) hmon = u32.MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST) if hmon and u32.GetMonitorInfoW(hmon, ctypes.byref(mi)): r = mi.rcWork return r.left, r.top, r.right - r.left, r.bottom - r.top except Exception: # noqa: BLE001 — не Windows / нет монитора pass return (0, 0, self.winfo_screenwidth(), max(self.winfo_screenheight() - 40, WIN_H)) def _toggle_maximize(self): if self._maximized: self._restore_from_max() else: self._maximize() def _maximize(self): """Разворачивает окно на рабочую область текущего монитора.""" if self._maximized: return self._restore_geom = self.geometry() self._maximized = True x, y, w, h = self._work_area() self.geometry("%dx%d+%d+%d" % (w, h, x, y)) self.btn_max.configure(text="❐") self.grip.place_forget() # размер при развороте не меняется def _restore_from_max(self): """Возвращает окно к размеру до разворота.""" if not self._maximized: return self._maximized = False if self._restore_geom: try: self.geometry(self._restore_geom) except tk.TclError: pass self.btn_max.configure(text="□") self.grip.place(relx=1.0, rely=1.0, anchor="se") def _unmaximize_under_cursor(self, e): """Перетаскивание развёрнутого окна: сначала вернуть размер.""" try: frac = ((e.x_root - self.winfo_rootx()) / max(self.winfo_width(), 1)) except Exception: # noqa: BLE001 frac = 0.5 self._restore_from_max() self.update_idletasks() # окно «прилипает» к курсору в той же точке строки nx = int(e.x_root - frac * self.winfo_width()) ny = e.y_root - 22 self.geometry("+%d+%d" % (nx, ny)) self.update_idletasks() # иначе rootx/rooty ещё старые self._drag_dx = e.x_root - self.winfo_rootx() self._drag_dy = e.y_root - self.winfo_rooty() 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._play_dir())) m.add_command(label=T("Открыть папку миров"), command=lambda: self._open_explorer( os.path.join(self._play_dir(), "saves"))) m.add_command(label=T("Открыть скриншоты"), command=lambda: self._open_explorer( os.path.join(self._play_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("", lambda ev: e.configure(highlightbackground=GREEN)) e.bind("", 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("<>", 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("", lambda e: save()) self._finish_dialog(d) # ------------------------------------------------------------ инстансы def _on_inst_scroll(self, first, last): """yscrollcommand панели инстансов: обновляет скроллбар.""" try: self._inst_sb.set(first, last) except (AttributeError, tk.TclError): pass self._toggle_inst_scroll() def _toggle_inst_scroll(self): """Показывает/прячет скроллбар панели инстансов. Скрывать нельзя pack_forget/pack — при повторной упаковке после Canvas Tk не даёт Scrollbar места и он не появится. Поэтому переключаем width: 0 (фактически 1px, незаметен) <-> 17. """ cv = getattr(self, "_inst_cv", None) sb = getattr(self, "_inst_sb", None) if cv is None or sb is None: return try: region = cv.bbox("all") if not region: return need = region[3] > cv.winfo_height() + 2 want = 17 if need else 0 if int(sb.cget("width") or 0) != want: sb.configure(width=want) except (tk.TclError, ValueError, TypeError): pass def _refresh_instances(self): """Перерисовывает панель инстансов слева.""" for w in self.inst_rows.winfo_children(): w.destroy() items = inst_mod.list_instances() active = self.settings.get("active_instance") or "" if not items: tk.Label(self.inst_rows, text=T("Нет инстансов"), bg=PANEL, fg=FAINT, font=("Segoe UI", 8), anchor="w").pack(fill="x", padx=6, pady=6) for it in items: meta = it["meta"] name = meta.get("name", "?") sel = (name == active) row = tk.Frame(self.inst_rows, bg=HOVER if sel else PANEL, cursor="hand2") row.pack(fill="x", padx=2, pady=1) # иконка (эмодзи или кастомная) icon = meta.get("icon") or "" txt = icon if icon and not icon.endswith(".png") else "📦" tk.Label(row, text=txt, bg=row["bg"], fg=TEXT, font=("Segoe UI", 12)).pack(side="left", padx=(6, 4), pady=4) info = tk.Frame(row, bg=row["bg"]) info.pack(side="left", fill="x", expand=True, pady=3) tk.Label(info, text=name, bg=row["bg"], fg=TEXT, font=("Segoe UI", 9, "bold"), anchor="w").pack(fill="x") ver = meta.get("version") or "?" loader = meta.get("loader") or "vanilla" if loader and loader != "vanilla": ver += " · " + loader tk.Label(info, text=ver, bg=row["bg"], fg=FAINT, font=("Segoe UI", 7), anchor="w").pack(fill="x") def make_select(n=name): return lambda e: self._select_instance(n) def make_ctx(n=name): return lambda e: self._instance_menu(e, n) row.bind("", make_select()) row.bind("", make_ctx()) for ch in row.winfo_children(): ch.bind("", make_select()) ch.bind("", make_ctx()) for g in ch.winfo_children(): g.bind("", make_select()) g.bind("", make_ctx()) # после перерисовки — спрятать скроллбар, если всё видно try: self.after(1, self._toggle_inst_scroll) except (AttributeError, tk.TclError): self._toggle_inst_scroll() def _select_instance(self, name): """Выбирает активный инстанс и применяет его настройки.""" items = inst_mod.list_instances() for it in items: if it["meta"].get("name") == name: self.settings["active_instance"] = name config.save_settings(self.settings) self._apply_instance(it) break self._refresh_instances() def _apply_instance(self, it): """Применяет настройки инстанса к UI и settings.""" meta = it["meta"] ver = meta.get("version") or "" if ver: self.settings["version"] = ver self.var_ver.set(ver) # пилюля и чип «Версия: …» внизу — одним вызовом try: self._update_pill() except (AttributeError, tk.TclError): pass ram = meta.get("ram") or 0 if ram: self.settings["ram"] = ram if meta.get("jvm_args") is not None: self.settings["jvm_args"] = meta.get("jvm_args") or "" if hasattr(self, "lbl_inst"): self.lbl_inst.configure(text="· " + meta.get("name", "")) config.save_settings(self.settings) def _current_instance(self): """Возвращает dict {dir, meta} текущего инстанса или None.""" name = self.settings.get("active_instance") or "" if not name: return None for it in inst_mod.list_instances(): if it["meta"].get("name") == name: return it return None def _open_new_instance(self): """Диалог создания нового инстанса.""" d = self._dialog(T("Новый инстанс"), width=420) body = tk.Frame(d, bg=BG) body.pack(fill="both", expand=True, padx=16, pady=14) tk.Label(body, text=T("Название"), bg=BG, fg=TEXT, anchor="w", font=("Segoe UI", 9)).pack(fill="x") e_name = tk.Entry(body, bg=INPUT, fg=TEXT, insertbackground=TEXT, relief="flat", bd=4, font=("Segoe UI", 10)) e_name.pack(fill="x", pady=(2, 10)) e_name.insert(0, "Новый мир") tk.Label(body, text=T("Версия"), bg=BG, fg=TEXT, anchor="w", font=("Segoe UI", 9)).pack(fill="x") cb_ver = ttk.Combobox(body, state="readonly", font=("Segoe UI", 9)) vers = [v for v in (self.settings.get("versions_list") or [])] if not vers: vers = ["latest-release", "latest-snapshot"] cb_ver["values"] = vers cb_ver.set(self.settings.get("version", "latest-release")) cb_ver.pack(fill="x", pady=(2, 10)) tk.Label(body, text=T("Загрузчик"), bg=BG, fg=TEXT, anchor="w", font=("Segoe UI", 9)).pack(fill="x") cb_loader = ttk.Combobox(body, state="readonly", font=("Segoe UI", 9)) cb_loader["values"] = ["vanilla", "fabric", "forge", "neoforge"] cb_loader.set("vanilla") cb_loader.pack(fill="x", pady=(2, 14)) def create(): nm = e_name.get().strip() or "Новый мир" d2 = inst_mod.create(nm, cb_ver.get(), cb_loader.get()) self.settings["active_instance"] = inst_mod.load_meta(d2)["name"] config.save_settings(self.settings) d.destroy() self._refresh_instances() it = self._current_instance() if it: self._apply_instance(it) self._log(T("Инстанс создан: ") + nm) btns = tk.Frame(body, bg=BG) btns.pack(fill="x") self._btn(btns, T("Создать"), create, primary=True).pack(side="right") self._btn(btns, T("Отмена"), d.destroy).pack(side="right", padx=(0, 6)) d.bind("", lambda e: create()) self._finish_dialog(d) def _instance_menu(self, event, name): """Контекстное меню инстанса (ПКМ).""" m = self.inst_menu m.delete(0, "end") def find(): for it in inst_mod.list_instances(): if it["meta"].get("name") == name: return it return None m.add_command(label=T("Запустить"), command=lambda: self._play_instance(name)) m.add_command(label=T("Настройки…"), command=lambda: self._open_instance_settings(name)) m.add_separator() m.add_command(label=T("Открыть папку"), command=lambda: self._open_instance_dir(name)) m.add_command(label=T("Дублировать"), command=lambda: self._dup_instance(name)) m.add_command(label=T("Экспорт…"), command=lambda: self._export_instance(name)) m.add_separator() m.add_command(label=T("Удалить"), command=lambda: self._del_instance(name)) try: m.tk_popup(event.x_root, event.y_root) finally: m.grab_release() def _play_instance(self, name): """Выбирает инстанс и запускает игру.""" self._select_instance(name) self._on_play() def _open_instance_dir(self, name): for it in inst_mod.list_instances(): if it["meta"].get("name") == name: self._open_explorer(inst_mod.game_dir(it["dir"])) break def _dup_instance(self, name): for it in inst_mod.list_instances(): if it["meta"].get("name") == name: inst_mod.duplicate(it["dir"]) self._refresh_instances() break def _export_instance(self, name): for it in inst_mod.list_instances(): if it["meta"].get("name") == name: try: path = inst_mod.export_zip(it["dir"], None) self._log(T("Экспортировано: ") + path) except Exception as ex: self._log(T("Ошибка экспорта: ") + str(ex), "err") break def _del_instance(self, name): if not messagebox.askyesno( "PixelLauncher", T("Удалить инстанс «") + name + T("»?") + "\n" + T("Миры будут удалены безвозвратно."), parent=self): return for it in inst_mod.list_instances(): if it["meta"].get("name") == name: inst_mod.delete(it["dir"]) if self.settings.get("active_instance") == name: self.settings["active_instance"] = "" config.save_settings(self.settings) break self._refresh_instances() def _open_instance_settings(self, name): """Диалог настроек инстанса (ОЗУ, Java, окно).""" for it in inst_mod.list_instances(): if it["meta"].get("name") == name: break else: return meta = it["meta"] d = self._dialog(T("Инстанс: ") + name, width=460) body = tk.Frame(d, bg=BG) body.pack(fill="both", expand=True, padx=16, pady=14) def row_label(txt): tk.Label(body, text=txt, bg=BG, fg=TEXT, anchor="w", font=("Segoe UI", 9)).pack(fill="x", pady=(8, 2)) row_label(T("ОЗУ (МБ, 0 = авто)")) e_ram = tk.Entry(body, bg=INPUT, fg=TEXT, insertbackground=TEXT, relief="flat", bd=4, font=("Segoe UI", 10)) e_ram.pack(fill="x") e_ram.insert(0, str(meta.get("ram") or 0)) row_label(T("JVM аргументы")) e_jvm = tk.Entry(body, bg=INPUT, fg=TEXT, insertbackground=TEXT, relief="flat", bd=4, font=("Segoe UI", 10)) e_jvm.pack(fill="x") e_jvm.insert(0, meta.get("jvm_args") or "") row_label(T("Заметка")) e_notes = tk.Entry(body, bg=INPUT, fg=TEXT, insertbackground=TEXT, relief="flat", bd=4, font=("Segoe UI", 10)) e_notes.pack(fill="x") e_notes.insert(0, meta.get("notes") or "") def save(): try: meta["ram"] = int(e_ram.get() or 0) except ValueError: meta["ram"] = 0 meta["jvm_args"] = e_jvm.get() meta["notes"] = e_notes.get() inst_mod.save_meta(it["dir"], meta) d.destroy() if self.settings.get("active_instance") == name: self._apply_instance(it) self._log(T("Настройки инстанса сохранены")) btns = tk.Frame(body, bg=BG) btns.pack(fill="x", pady=(14, 0)) self._btn(btns, T("Сохранить"), save, primary=True).pack(side="right") self._btn(btns, T("Отмена"), d.destroy).pack(side="right", padx=(0, 6)) d.bind("", 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("", 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._play_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("", 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._play_dir()): on = base_of(m["file"]) in want if m["enabled"] != on: mods_mod.set_enabled(self._play_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._play_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._play_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("<>", 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._play_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._play_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._play_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._play_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("", 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("", lambda e: choose()) self._finish_dialog(d) return d def _build_quick_bar(self, parent): """Кликабельные чипы быстрых настроек на главном экране. Дублируют подменю ⚙ → «Быстрые настройки»: язык, масштаб, автозагрузка, трей и авто-бэкап меняются одним кликом. При узком окне чипы переносятся на следующие строки, а не обрезаются правым краем. """ def _avail(): """Ширина под чипы с запасом по краям панели. 0 — ширина ещё неизвестна (окно не отрисовано): построим чипы позже, когда придёт первый . """ w = parent.winfo_width() - 12 if w <= 40 and parent.master is not None: w = parent.master.winfo_width() - 12 return w if w > 40 else 0 state = {"w": 0} # ширина, под которую чипы сейчас разложены def build(_event=None): """Пересоздаёт чипы строками ровно по ширине панели.""" avail = _avail() if avail <= 0: state["w"] = 0 # ждём первого с реальной шириной return state["w"] = avail for old in list(parent.winfo_children()): old.destroy() # прежние строки и чипы chips = [] 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") chips.append(lbl) lbl.bind("", lambda e: command()) lbl.bind("", lambda e: lbl.configure(bg=HOVER, fg=TEXT)) lbl.bind("", 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"))) # дробим на строки: всё, что не влезло в ширину, — на новую row = mid = None x = 0 for lbl in chips: w = lbl.winfo_reqwidth() + 6 # межчиповый отступ if row is None or (x and x + w > avail): row = tk.Frame(parent, bg=BG) # колонки-пустышки с весом центрируют строку сами row.columnconfigure(0, weight=1) row.columnconfigure(2, weight=1) tk.Frame(row, bg=BG).grid(row=0, column=0) tk.Frame(row, bg=BG).grid(row=0, column=2) mid = tk.Frame(row, bg=BG) mid.grid(row=0, column=1) row.pack(side="top", fill="x") x = 0 lbl.pack(in_=mid, side="left") x += w # строки создаются позже чипов и в X-порядке их перекрывают # (чипы — дети bar, строки — тоже): поднимаем чипы наверх for lbl in chips: lbl.lift() # перенос строк мог увеличить естественную высоту окна self.after_idle(self._fit_window_height) def on_resize(_event=None): """Разбивает чипы заново, только если ширина реально изменилась.""" if _avail() == state["w"]: return if getattr(self, "_quick_after", None): try: self.after_cancel(self._quick_after) except Exception: # noqa: BLE001 — уже отменён/выполнен pass def later(): self._quick_after = None try: alive = parent.winfo_exists() except Exception: # noqa: BLE001 — окно уже пересобрано alive = False if alive: build() self._quick_after = self.after(40, later) parent.bind("", on_resize) build() @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._play_dir())).pack(side="left") self._btn(row, T("Открыть папку миров"), lambda: self._open_explorer( os.path.join(self._play_dir(), "saves"))).pack(side="left", padx=(6, 0)) self._btn(row, T("Открыть скриншоты"), lambda: self._open_explorer( os.path.join(self._play_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("", 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. Пустой хост = выключен.")) # --- Ключ CurseForge API (необязателен) tk.Label(body, text=T("Ключ CurseForge API (необязательно):"), bg=BG, fg=DIM, anchor="w", font=("Segoe UI", 9)).pack(anchor="w", pady=(12, 2)) var_cf_key = tk.StringVar(value=self.settings.get("cf_api_key") or "") tk.Entry(body, textvariable=var_cf_key, width=40, font=("Segoe UI", 9), bg=INPUT, fg=TEXT, insertbackground=TEXT, relief="solid", bd=1, highlightthickness=0).pack(fill="x") self._hint(body, T("Без ключа сборки берутся с зеркала CurseForge.")) # --- Масштаб интерфейса 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) self.settings["cf_api_key"] = var_cf_key.get().strip() 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")) curseforge_mod.set_api_key(self.settings.get("cf_api_key")) 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("", lambda e: ent.configure(highlightbackground=GREEN)) ent.bind("", 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)) # --- список сборок: строки с иконками (canvas + скролл) --- listwrap = tk.Frame(body, bg=INPUT, highlightthickness=1, highlightbackground=INPUT_BD, relief="solid") listwrap.pack(fill="both", expand=True, pady=(4, 0)) cv = tk.Canvas(listwrap, bg=INPUT, highlightthickness=0, bd=0) sb = tk.Scrollbar(listwrap, orient="vertical", command=cv.yview) cv.configure(yscrollcommand=sb.set) sb.pack(side="right", fill="y") cv.pack(side="left", fill="both", expand=True) rows_holder = tk.Frame(cv, bg=INPUT) cv_win = cv.create_window((0, 0), window=rows_holder, anchor="nw") rows_holder.bind("", lambda e: cv.configure(scrollregion=cv.bbox("all"))) cv.bind("", lambda e: cv.itemconfigure(cv_win, width=e.width)) ICON_PX = 34 sel_idx = {"i": None} row_widgets = [] # (фрейм, метка иконки, метка текста) photos = [] # ссылки на PhotoImage, чтобы Tk не освободил def _prep_icon(raw, box=ICON_PX): """Фоновый поток: подготовка иконки через Pillow (или None).""" if _PILImage is None: return None try: im = _PILImage.open(_pil_io.BytesIO(raw)).convert("RGBA") # логотипы часто с прозрачными полями — обрезаем их bbox = im.getbbox() if bbox: im = im.crop(bbox) w0, h0 = im.size if w0 and h0: k = box / float(max(w0, h0)) if k != 1.0: im = im.resize((max(1, int(round(w0 * k))), max(1, int(round(h0 * k)))), _PILImage.LANCZOS) canvas = _PILImage.new("RGBA", (box, box), (0, 0, 0, 0)) canvas.paste(im, ((box - im.width) // 2, (box - im.height) // 2)) mask = _PILImage.new("L", (box, box), 0) _PILDraw.Draw(mask).rounded_rectangle( (0, 0, box - 1, box - 1), radius=max(4, box // 7), fill=255) canvas.putalpha(mask) return canvas except Exception: # noqa: BLE001 return None def _fit_icon_tk(raw, box=ICON_PX): """Запасной путь без Pillow: Tk subsample + квадратный фон, чтобы неквадратные иконки не выглядели «осколком».""" img = tk.PhotoImage(data=raw) w, h = img.width(), img.height() k = max(w, h) / float(box) if k > 1.0: img = img.subsample(max(1, int(k)), max(1, int(k))) w, h = img.width(), img.height() if w != box or h != box: sq = tk.PhotoImage(width=box, height=box) sq.tk.call(str(sq), "copy", str(img), "-to", max(0, (box - w) // 2), max(0, (box - h) // 2)) return sq return img def _scroll_wheel(e): cv.yview_scroll(-1 if e.delta > 0 else 1, "units") def _icon_worker(url, lab): """Скачивает иконку в фоне, отдаёт в GUI-поток через after.""" # Modrinth отдаёт WEBP с суффиксом _NN — конвертируем в PNG orig_url = url if "cdn.modrinth.com" in url and url.endswith(".webp"): fname = url.rsplit("/", 1)[-1].rsplit("_", 1)[0] url = url.rsplit("/", 1)[0] + "/" + fname + ".png" raw = None for u in (url, orig_url): try: raw = http.get_bytes(u, timeout=20) break except Exception: # noqa: BLE001 continue if raw is None: return prepared = _prep_icon(raw) # в фоне: сглаживание Pillow def put(): try: if not lab.winfo_exists(): return img = None if prepared is not None and _PILImageTk is not None: try: img = _PILImageTk.PhotoImage(prepared) except Exception: # noqa: BLE001 img = None if img is None: img = _fit_icon_tk(raw) photos.append(img) lab.configure(image=img, text="") except Exception: # noqa: BLE001 — битая картинка pass try: if d.winfo_exists(): d.after(0, put) except Exception: # noqa: BLE001 — окно уже закрыли pass def _select(i): sel_idx["i"] = i for j, (fr, ic, tx) in enumerate(row_widgets): try: bgc = GREEN if j == i else INPUT fr.configure(bg=bgc) ic.configure(bg=bgc, fg="#0d1a0d" if j == i else DIM) tx.configure(bg=bgc, fg="#0d1a0d" if j == i else TEXT) except tk.TclError: pass load_files() def _fill_rows(items): for fr, _ic, _tx in row_widgets: fr.destroy() row_widgets.clear() del photos[:] sel_idx["i"] = None for i, it in enumerate(items): fr = tk.Frame(rows_holder, bg=INPUT) fr.pack(fill="x", padx=4, pady=2) icon = tk.Label(fr, text="📦", width=3, bg=INPUT, fg=DIM, font=("Segoe UI", 12)) icon.pack(side="left", padx=(8, 8), pady=4) 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])) gv = [g for g in (it.get("game_versions") or []) if g] if gv: bits.append(str(gv[0])) tail = (" [" + " · ".join(bits) + "]") if bits else "" txt = tk.Label(fr, text="%s — %s%s" % (title, desc, tail), bg=INPUT, fg=TEXT, font=("Segoe UI", 9), anchor="w", justify="left", wraplength=540) txt.pack(side="left", fill="x", expand=True, pady=4) row_widgets.append((fr, icon, txt)) for w in (fr, icon, txt): w.bind("", lambda e, i=i: _select(i)) w.bind("", _scroll_wheel) url = it.get("icon") or "" if url: threading.Thread(target=_icon_worker, args=(url, icon), daemon=True).start() 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 files.clear() lb_f.delete(0, "end") _fill_rows(out) 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 = sel_idx["i"] lb_f.delete(0, "end") files.clear() if sel is None or sel >= len(packs): lbl_files.configure(text=T("Файлы сборки"), fg=DIM) return p = packs[sel] 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 = sel_idx["i"] if sel is None or sel >= len(packs): status.configure(text=T("Выберите сборку"), fg=ERR) return if self.busy: status.configure( text=T("Дождитесь окончания текущей загрузки"), fg=ERR) return p = packs[sel] 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._play_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._play_dir(), log=log, progress=prog, cancel=cancel) self._install_pack_async( worker, T("Установка сборки: ") + str(title)) var_q.trace_add("write", schedule_search) lb_f.bind("", 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("", lambda e: goto(1)) e_find.bind("", lambda e: goto(-1)) txt.bind("", 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("", 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._play_dir()): return ups = mods_mod.check_updates(self._play_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("", 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("<>", 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._play_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._play_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._play_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._play_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._play_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._play_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._play_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("", 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("", 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("", 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("<>", 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 _play_dir(self): """Каталог игры для запуска/контента: активный инстанс или общий. Общие ресурсы (versions/assets/libraries/.launcher) всегда в _game_dir(); сюда попадают mods/saves/config выбранного инстанса. """ it = self._current_instance() if it: return inst_mod.game_dir(it["dir"]) return self._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._play_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("", lambda e: cv.configure(scrollregion=cv.bbox("all"))) cv.bind("", lambda e: cv.itemconfigure(cv_win, width=e.width)) cv.bind("", 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("", 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._play_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("", 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) # каталог игры выбранного инстанса (mods/saves), может отличаться play_dir = self._play_dir() if play_dir != gd: os.makedirs(play_dir, 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(play_dir, 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", ""), game_directory=play_dir) # размер окна/полный экран из профиля 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( play_dir, 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()