launcher_exe.py
Rama main · réplica de lectura del repositorio original.
"""Ripster.exe entry point — the FROZEN launcher (PyInstaller one-file).
Double-click Ripster.exe → starts the bundled server and shows the UI in its own
native window (pywebview / Edge WebView2). No browser tab, no .cmd/.vbs, no terminal.
Falls back to the default browser if the system webview is unavailable.
Deliberately has NO `ripster.*` imports: the frozen exe stays tiny and can't be
bricked by an app-side import error — it only orchestrates the bundled interpreter.
The real server is `python\\python.exe app.py`, started as a windowless child.
Run dir = the folder Ripster.exe lives in (the install root), resolved from
sys.executable when frozen so it never points at PyInstaller's temp _MEIPASS.
"""
from __future__ import annotations
import contextlib
import html
import json
import os
import subprocess
import sys
import threading
import time
import webbrowser
from pathlib import Path
from urllib.request import urlopen
CREATE_NO_WINDOW = 0x08000000 # Windows: child server gets no console window
def base_dir() -> Path:
"""Install root. Frozen → folder of Ripster.exe; source → this script's folder."""
if getattr(sys, "frozen", False):
return Path(sys.executable).resolve().parent
return Path(__file__).resolve().parent
BASE = base_dir()
def _echo(msg: str) -> None:
"""Console line, degrades instead of raising: a cp1251 console (pythonw has one,
and a test harness that pipes our stdio) cannot encode ▶ or →, and an unhandled
UnicodeEncodeError here would abort open_window / the watchdog thread."""
try:
print(msg, flush=True)
except Exception:
try:
enc = getattr(sys.stdout, "encoding", None) or "ascii"
print(msg.encode(enc, "replace").decode(enc, "replace"), flush=True)
except Exception:
pass
def launcher_log_path() -> Path:
"""Куда писать диагностику запуска. RIPSTER_LAUNCHER_LOG — явный override
для тестов: без него боевой logs/launcher.log засоряется записями, которых
лаунчер никогда не выдавал («webview halted» из-под monkeypatch)."""
env = os.environ.get("RIPSTER_LAUNCHER_LOG")
if env:
return Path(env)
return BASE / "logs" / "launcher.log"
def _log(msg: str) -> None:
_echo(msg)
try:
logf = launcher_log_path()
logf.parent.mkdir(parents=True, exist_ok=True)
with open(logf, "a", encoding="utf-8") as f:
f.write(msg + "\n")
except Exception:
pass
def config_port() -> int:
"""The port the UI lives on. RIPSTER_PORT env wins (matches app.py's own
precedence), then config.yaml `port`, then 7799."""
env = os.environ.get("RIPSTER_PORT")
if env and env.strip().isdigit():
return int(env.strip())
try:
import yaml
c = yaml.safe_load((BASE / "config.yaml").read_text(encoding="utf-8")) or {}
return int(c.get("port", 7799))
except Exception:
return 7799
def server_url() -> str:
# 127.0.0.1, NOT localhost (Spotify OAuth rejects localhost since Apr 2025).
return f"http://127.0.0.1:{config_port()}"
def server_python() -> str:
"""A REAL interpreter to run app.py — never sys.executable when frozen,
because that IS Ripster.exe: spawning it re-enters the launcher, which sees
the parent's lock file, logs "already running → surfacing it, exiting" and
dies without ever starting a server. The launcher then respawns it forever.
Seen in the wild on 3.0.38 (path `…\\Raccoon-Ripster-3.0.38\\Raccoon-Ripster-3.0.38\\`
= the GitHub *source* zip): that archive has no bundled `python/`, so the
old last-resort branch handed back Ripster.exe and the app never started.
Returns "" when no real interpreter exists — the caller reports that
plainly instead of looping."""
for cand in (BASE / "python" / "python.exe",
BASE / ".venv" / "Scripts" / "python.exe"):
if cand.exists():
return str(cand)
frozen = getattr(sys, "frozen", False)
if frozen:
return "" # no interpreter shipped → cannot run the server
return sys.executable # plain dev run: this really is a python.exe
def ripster_alive(port: int, timeout: float = 1.5) -> bool:
"""True only if OUR Ripster answers on `port` — verified via the unauth
/api/ping marker. A foreign app squatting the port does NOT count (so we pick
a free port instead of opening a window onto someone else's server)."""
import json
try:
with urlopen(f"http://127.0.0.1:{port}/api/ping", timeout=timeout) as r:
return json.loads(r.read() or b"{}").get("app") == "ripster"
except Exception:
return False
def port_free(port: int) -> bool:
"""True if nothing is bound on 127.0.0.1:`port` (we can take it)."""
import socket
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
s.bind(("127.0.0.1", port))
return True
except OSError:
return False
def find_free_port(start: int, tries: int = 50) -> int:
"""First free port at or after `start` (skips busy ones automatically — a clean
install on a machine where 7799 is already taken still launches)."""
for p in range(start, start + tries):
if port_free(p):
return p
return start # all busy in range — let the bind fail loudly downstream
def wait_for_ripster(port: int, timeout: float = 60.0) -> bool:
deadline = time.time() + timeout
while time.time() < deadline:
if ripster_alive(port):
return True
time.sleep(1.0)
return False
def start_server(port: int) -> "subprocess.Popen | None":
"""Start app.py on a SPECIFIC port. RIPSTER_PORT is exported so app.py binds
exactly the port the launcher will open the window on (config.yaml `port` no
longer has to agree — the launcher is the single source of truth)."""
py = server_python()
if not py:
# No bundled interpreter (source zip instead of the installer). Say so
# once, in words the user can act on, and do NOT respawn — an endless
# restart loop is what this cost people on 3.0.38.
_log("[launcher] НЕТ интерпретатора: в этой папке отсутствует bundled python\\.")
_log("[launcher] Похоже, скачан архив с ИСХОДНЫМ КОДОМ вместо установщика.")
_log("[launcher] Поставь Ripster установщиком RipsterSetup-*.exe со страницы релиза.")
try:
(BASE / "logs").mkdir(parents=True, exist_ok=True)
(BASE / "logs" / "ЧИТАЙ_МЕНЯ_ошибка_запуска.txt").write_text(
"Ripster не запустился: в папке нет встроенного Python.\n\n"
"Скорее всего скачан архив «Source code» со страницы релиза —\n"
"в нём нет ни Python, ни зависимостей, только исходный код.\n\n"
"Что делать: скачать RipsterSetup-<версия>.exe со страницы релиза\n"
"и установить им. Либо, если нужен запуск из исходников,\n"
"создать окружение: python -m venv .venv && "
".venv\\Scripts\\pip install -r requirements.txt\n",
encoding="utf-8")
except Exception:
pass
return None
env = dict(os.environ)
env["RIPSTER_PORT"] = str(port)
env["RIPSTER_LAUNCHER"] = "1" # tells /api/restart we supervise it → clean exit, no os.execv console flash
_log(f"[launcher] starting server: {py} app.py (cwd={BASE}, port={port})")
flags = CREATE_NO_WINDOW if os.name == "nt" else 0
try:
return subprocess.Popen([py, "app.py"], cwd=str(BASE),
creationflags=flags, env=env)
except Exception as e:
_log(f"[launcher] failed to start server: {type(e).__name__}: {e}")
return None
# ── Режимы запуска ───────────────────────────────────────────────────────────
def isolated() -> bool:
"""Изолированный прогон нового exe рядом с живым стеком: RIPSTER_ISOLATED=1
(или --isolated). Не поднимает Docker и контейнеры и не трогает бота совсем:
тест «поднял ли сторож сервер» иначе ударил бы по рабочему 7799 и по живому
bot.py (второй экземпляр бота = двойная доставка)."""
env = (os.environ.get("RIPSTER_ISOLATED") or "").strip().lower()
if env in ("1", "true", "yes", "on"):
return True
return "--isolated" in sys.argv
def iso_blocked(key: str) -> bool:
"""В изолированном прогоне новые Docker, контейнеры и бот запрещены и
кнопкам: проверка нового exe не должна ронять живой стек владельца."""
return isolated() and key in ("docker", "containers", "bot")
# ── Строки интерфейса лончера ─────────────────────────────────────────────────
# Окно лончера — это страница сервера, поэтому i18n.js сюда не доезжает: свои
# ru+en по тому же принципу «ключ → язык», что и во фронтенде. Язык: env
# RIPSTER_LANG (для тестов), иначе ru — весь существующий UI лончера русский.
_UI = {
"panel.title": {"ru": "Ripster · состояние", "en": "Ripster · status"},
"panel.docker": {"ru": "Docker", "en": "Docker"},
"panel.containers": {"ru": "Контейнеры", "en": "Containers"},
"panel.server": {"ru": "Сервер", "en": "Server"},
"panel.bot": {"ru": "Бот", "en": "Bot"},
"panel.tunnel": {"ru": "Туннель", "en": "Tunnel"},
"panel.restarted": {"ru": "перезапуск {time}", "en": "restarted {time}"},
"st.up": {"ru": "работает", "en": "up"},
"st.down": {"ru": "не запущен", "en": "down"},
"st.starting": {"ru": "запускается", "en": "starting"},
"st.restarting": {"ru": "перезапускаем", "en": "restarting"},
"st.stopping": {"ru": "останавливаем", "en": "stopping"},
"st.dead": {"ru": "упал и не поднимается", "en": "down, not recovering"},
"st.off": {"ru": "выключен", "en": "off"},
"st.not_monitored": {"ru": "не следим", "en": "not monitored"},
"st.no_hc": {"ru": "нет модуля проверок", "en": "health-check module missing"},
"st.wait_docker": {"ru": "ждёт Docker", "en": "waiting for Docker"},
"st.missing": {"ru": "нет: {names}", "en": "missing: {names}"},
"st.all": {"ru": "{n} из {n}", "en": "{n} of {n}"},
"st.port": {"ru": "порт {port}", "en": "port {port}"},
"st.no_answer": {"ru": "не отвечает", "en": "no answer"},
"tray.open": {"ru": "Открыть Ripster", "en": "Open Ripster"},
"tray.quit": {"ru": "Выход", "en": "Выход"},
"tray.autostart": {"ru": "Запускать при входе", "en": "Start at sign-in"},
"tray.overlay": {"ru": "Панель состояния в окне", "en": "Status panel in window"},
"tray.autostart_on": {"ru": "Буду стартовать при входе в Windows",
"en": "Will start at Windows sign-in"},
"tray.autostart_off": {"ru": "Автозапуск при входе выключен",
"en": "Start-at-sign-in is off"},
"tray.autostart_err": {"ru": "Автозапуск не переключился: {err}",
"en": "Could not toggle autostart: {err}"},
"tray.status_title": {"ru": "Состояние", "en": "Status"},
# Панель управления в окне (кнопки/чекбоксы) — те же ru+en, что и трей.
"panel.start_all": {"ru": "▶ Запустить всё", "en": "▶ Start everything"},
"panel.autostart": {"ru": "Запускать при входе в Windows",
"en": "Start at Windows sign-in"},
"st.stopped": {"ru": "остановлен", "en": "stopped"},
"st.not_ours": {"ru": "запущен не лончером — не трогаю",
"en": "started outside the launcher — leaving alone"},
"st.fail": {"ru": "не вышло: {err}", "en": "failed: {err}"},
"splash.server_off_t": {"ru": "Сервер выключен", "en": "Server is off"},
"splash.server_off_d": {"ru": "Включи «Сервер» чекбоксом и нажми «Запустить всё».",
"en": "Tick \"Server\" and press \"Start everything\"."},
}
_LANG = None
def lang() -> str:
global _LANG
if _LANG is None:
env = (os.environ.get("RIPSTER_LANG") or "").strip().lower()
_LANG = "en" if env.startswith("en") else "ru"
return _LANG
def t(key: str, **fmt) -> str:
d = _UI.get(key) or {}
txt = d.get(lang()) or d.get("ru") or key
return txt.format(**fmt) if fmt else txt
# ── Хелперы подъёма стека (общие со сторожем) ─────────────────────────────────
_HC: object | None = None
_HC_TRIED = False
def healthcheck_module():
"""`tools/ripster_healthcheck.py` как библиотека, а не второй экземпляр логики.
Там уже живёт единственный проверенный способ поднять Docker Desktop и
дождаться движка (`ensure_docker_engine`, из `_heal_app_down`), поднять
контейнер (`ensure_container`) и запустить бота тем интерпретатором, который
сторож считает «живым» (`start_bot_process`). Переписать это в лончере
означало бы две версии одного порядка, расходящиеся при первой же правке.
Ищем рядом с exe, а в установщике, где каталога `tools/` нет, — упакованную
копию из временной папки PyInstaller."""
global _HC, _HC_TRIED
if _HC_TRIED:
return _HC
_HC_TRIED = True
import importlib.util
roots = [BASE / "tools"]
meipass = getattr(sys, "_MEIPASS", None)
if meipass:
roots.append(Path(meipass) / "tools")
for d in roots:
f = d / "ripster_healthcheck.py"
if not f.exists():
continue
try:
spec = importlib.util.spec_from_file_location("ripster_healthcheck", str(f))
m = importlib.util.module_from_spec(spec)
spec.loader.exec_module(m) # только stdlib, побочных действий на импорте нет
# У упакованной копии ROOT указывает в _MEIPASS — поправишь на корень установки,
# иначе конфиги и cwd бота резолвятся во временную папку.
m.ROOT = BASE
m.CONFIG = BASE / "config.yaml"
m.BOT_CFG = BASE / "tgbot" / "config.json"
_HC = m
_log(f"[launcher] health-check helpers: {f}")
return m
except Exception as e:
_log(f"[launcher] health-check import failed ({f}): {type(e).__name__}: {e}")
_log("[launcher] health-check module not found — Docker/контейнеры/бот на себя не беру")
return None
def _hc_log(msg: str) -> None:
"""Сообщения healthcheck-хелперов (`fixed`) идут в лог лончера, а не в отчёт
владельцу: здесь у модуля нет ни репорта, ни HANDOFF."""
_log(f"[stack] {msg}")
# ── Панель состояния ──────────────────────────────────────────────────────────
OK, WARN, BAD, OFF = "ok", "warn", "bad", "off"
_DOT = {OK: "🟢", WARN: "🟡", BAD: "🔴", OFF: "⚪"}
_HEX = {OK: "#3ddc84", WARN: "#ffcc4d", BAD: "#ff5f56", OFF: "#8a8a94"}
class Status:
"""Уровни подсистем + когда каждую в последний раз поднимали. Пишут треды
старта, сторожа и опроса; читают окно, трей и лог."""
KEYS = ("docker", "containers", "server", "bot", "tunnel")
def __init__(self):
self._lock = threading.Lock()
self._s = {k: {"level": OFF, "detail": "", "restart": 0.0} for k in self.KEYS}
def set(self, key: str, level: str, detail: str = "", restarted: bool = False) -> None:
with self._lock:
it = self._s.setdefault(key, {"level": OFF, "detail": "", "restart": 0.0})
it["level"], it["detail"] = level, detail
if restarted: # время ставит только тот, кто РЕАЛЬНО поднимал
it["restart"] = time.time()
def snapshot(self) -> dict:
with self._lock:
return {k: dict(v) for k, v in self._s.items()}
def line(self, key: str) -> str:
v = self.snapshot()[key]
stamp = _hhmm(v["restart"])
txt = f"{t('panel.' + key)} {_DOT[v['level']]} {v['detail']}".strip()
return f"{txt} · {t('panel.restarted', time=stamp)}" if stamp else txt
def _hhmm(ts: float) -> str:
return time.strftime("%H:%M", time.localtime(ts)) if ts else ""
STATUS = Status()
# ── Компоненты: чекбоксы, запуск/остановка по одному, флаги сторожа ───────────
# ENABLED — что лончер вообще считает «своим»: входит в «Запустить всё»,
# за ним следит сторож. Хранится рядом с геометрией окна (logs/window_state*),
# а не в config.yaml: это настройки именно лончера, а не сервера.
# WANTED — что лончер поднимал (или взял под присмотр) и обязан оживлять;
# ручная остановка снимает WANTED, иначе сторож воскреет то, что гасили руками.
# BUSY — идёт действие («start»/«stop»): сторож в это время не мешает, панель
# рисует «запускается/останавливаем» вместо устаревшего снимка пробников.
COMPONENTS = ("docker", "containers", "server", "bot")
ENABLED = {k: True for k in COMPONENTS}
WANTED: dict = {k: False for k in COMPONENTS}
BUSY: dict = {k: False for k in COMPONENTS}
# Ключ живёт только на время Popen/terminate: сторож обязан спать, пока его
# иначе параллельная остановка и подъём меняют `box[0]` друг у друга.
TAKING: dict = {k: False for k in COMPONENTS}
CTX: dict = {} # port/box/bot_box/hc/own/iso/win_open/stop/stack_done — из main()
SPLASH = {"active": False, "url": ""} # окно ещё на «запускаюсь», не на сервере
_BTN = ('border:1px solid rgba(255,255,255,.22);background:#1d1d26;color:#eef0f6;'
'border-radius:6px;padding:1px 7px;font-size:11px;line-height:1.5;cursor:pointer')
def _off_detail(key: str) -> str:
"""Почему компонент не под присмотром: изолированный прогон, снятый чекбокс
или ручная остановка кнопкой — три разные причины, и владельцу важно
различать их в панели."""
if iso_blocked(key):
return t("st.not_monitored")
if not ENABLED.get(key):
return t("st.off")
if not WANTED.get(key):
return t("st.stopped")
return ""
def _overlay_html(snap: dict) -> str:
"""Панель управления внутри окна приложения: «Запустить всё», по чекбоксу
и кнопкам ▶/■ на компонент, галочка автозапуска, состояние и время
последнего перезапуска. Окно — чужая страница, поэтому всё инлайном и без
классов (не словить конфликт с CSS Ripster). В раскрытом виде панель
ловит клики (иначе кнопки нерабочи); свернутая в строчку занимает три
миллиметра и мешать не может.
Кнопки шлют вызовы в `pywebview.api` (LauncherApi), а ответом идёт не HTML
кнопки, а новая команда Python-рендерa: состояние живёт только в лончере."""
collapsed = not UI_PREFS.get("panel_open", True)
arrow = "▸" if collapsed else "▾"
head = ('<div style="display:flex;justify-content:space-between;align-items:center;'
'gap:10px;font-weight:600;opacity:.8;margin-bottom:4px">'
f'<span>{html.escape(t("panel.title"))}</span>'
f'<button data-act="panel" style="{_BTN}">{arrow}</button></div>')
if collapsed:
return head
rows = [head,
f'<button data-act="start_all" style="{_BTN};margin:0 0 6px">'
f'{html.escape(t("panel.start_all"))}</button>']
for key in Status.KEYS:
v = snap.get(key) or {}
lvl = v.get("level", OFF)
stamp = _hhmm(v.get("restart") or 0)
row = ('<div style="display:flex;gap:6px;align-items:center;white-space:nowrap">'
f'<span style="color:{_HEX.get(lvl, _HEX[OFF])}">●</span>')
if key in COMPONENTS:
row += (f'<input type="checkbox" data-pref="enabled" data-key="{key}"'
+ (" checked" if ENABLED[key] else "")
+ ' style="accent-color:#c084a0;margin:0">')
row += (f'<span style="opacity:.72;min-width:64px">{html.escape(t("panel." + key))}</span>'
f'<span style="flex:1">{html.escape(v.get("detail") or "")}</span>')
if stamp:
row += f'<span style="opacity:.5">{html.escape(stamp)}</span>'
if key in COMPONENTS:
busy = "disabled" if BUSY.get(key) else ""
start_dis = busy if ENABLED[key] else "disabled"
row += (f'<button data-act="start" data-key="{key}" {start_dis} '
f'style="{_BTN}">▶</button>'
f'<button data-act="stop" data-key="{key}" {busy} '
f'style="{_BTN}">■</button>')
rows.append(row + "</div>")
try:
auto_on = autostart_enabled()
except Exception:
auto_on = False
rows.append('<div style="display:flex;gap:6px;align-items:center;margin-top:6px;'
'white-space:nowrap"><input type="checkbox" data-pref="autostart"'
+ (" checked" if auto_on else "")
+ ' style="accent-color:#c084a0;margin:0">'
+ f'<span>{html.escape(t("panel.autostart"))}</span></div>')
return "".join(rows)
_OVERLAY_JS = """
(function () {
var ID = 'ripster-launch-status';
var host = document.getElementById(ID);
if (!host) {
host = document.createElement('div');
host.id = ID;
host.style.cssText = 'position:fixed;left:14px;bottom:78px;z-index:2147483000;'
+ 'font:11px/1.55 -apple-system,Segoe UI,sans-serif;'
+ 'color:#eef0f6;background:rgba(12,12,16,.82);border:1px solid rgba(255,255,255,.10);'
+ 'border-radius:10px;padding:8px 11px;box-shadow:0 6px 24px rgba(0,0,0,.45)';
(document.body || document.documentElement).appendChild(host);
}
var html = %(payload)s;
if (html === null) { host.remove(); return; }
host.innerHTML = html;
host.style.pointerEvents = 'auto';
var api = window.pywebview && window.pywebview.api;
if (!api) { return; } // браузерный фолбэк: панели рисовать нечем, только статус
host.querySelectorAll('[data-act]').forEach(function (el) {
el.addEventListener('click', function () {
api.action(el.getAttribute('data-act'), el.getAttribute('data-key') || '');
});
});
host.querySelectorAll('input[data-pref]').forEach(function (el) {
el.addEventListener('change', function () {
api.pref(el.getAttribute('data-pref'), el.getAttribute('data-key') || '', el.checked);
});
});
})();
"""
def push_overlay(window, enabled: bool, snap: dict) -> None:
if window is None:
return
try:
payload = "null" if not enabled else json.dumps(_overlay_html(snap))
window.evaluate_js(_OVERLAY_JS % {"payload": payload})
except Exception as e:
# Перезагрузка страницы/закрытое окно — штатные причины; следующий тик
# опроса перерисует панель заново (она пересоздаётся по id).
_log(f"[launcher] status overlay skipped: {type(e).__name__}: {e}")
def render_panel() -> None:
"""Перерисовать панель немедленно — после клика ждать 12 с до следующего
тика опроса нельзя, пользователь решит, что кнопка не нажалась."""
try:
push_overlay(WINDOW, UI_PREFS["overlay"], STATUS.snapshot())
except Exception as e:
_log(f"[launcher] panel render failed: {type(e).__name__}: {e}")
def refresh_status(hc, port: int, stack_done: threading.Event) -> dict:
"""Один снимок всего стека — то же, что видят окно и трей."""
for key in COMPONENTS:
if BUSY.get(key):
STATUS.set(key, WARN, t("st.starting") if BUSY[key] == "start"
else t("st.stopping"))
if hc is None:
STATUS.set("docker", OFF, t("st.no_hc"))
STATUS.set("containers", OFF, t("st.no_hc"))
else:
engine, probed = False, False
if not ENABLED["docker"]:
STATUS.set("docker", OFF, _off_detail("docker"))
elif not BUSY.get("docker"):
try:
engine, probed = hc.docker_engine_up(timeout=6), True
except Exception as e:
_log(f"[launcher] docker probe failed: {type(e).__name__}: {e}")
if engine:
STATUS.set("docker", OK, t("st.up"))
elif not stack_done.is_set():
STATUS.set("docker", WARN, t("st.starting"))
else:
STATUS.set("docker", BAD if WANTED["docker"] else OFF,
t("st.down") if WANTED["docker"] else _off_detail("docker"))
if BUSY.get("containers"):
pass
elif not ENABLED["containers"]:
STATUS.set("containers", OFF, _off_detail("containers"))
else:
if not probed: # Docker чекбоксом выключен — но контейнеры живут на
try: # движке, и его состояние надо честно прочитать
engine = hc.docker_engine_up(timeout=6)
except Exception:
engine = False
if not engine:
STATUS.set("containers", OFF, t("st.wait_docker"))
else:
try:
running = hc.running_container_names()
except Exception:
running = set()
wanted = list(getattr(hc, "REQUIRED_CONTAINERS", ()))
missing = [c for c in wanted if c not in running]
if not wanted:
STATUS.set("containers", OFF, t("st.no_hc"))
elif missing and not stack_done.is_set():
STATUS.set("containers", WARN, t("st.starting"))
elif missing:
STATUS.set("containers", BAD if len(missing) == len(wanted) else WARN,
t("st.missing", names=", ".join(missing)))
else:
STATUS.set("containers", OK, t("st.all", n=len(wanted)))
if BUSY.get("server"):
pass
elif not ENABLED["server"]:
STATUS.set("server", OFF, _off_detail("server"))
else:
alive = ripster_alive(port, timeout=2.0)
if alive:
STATUS.set("server", OK, t("st.port", port=port))
elif stack_done.is_set() and WANTED["server"]:
STATUS.set("server", BAD, f"{port} · {t('st.down')}")
elif not stack_done.is_set():
STATUS.set("server", WARN, f"{port} · {t('st.starting')}")
else:
STATUS.set("server", OFF, _off_detail("server") or t("st.down"))
if BUSY.get("bot"):
pass
elif hc is None:
STATUS.set("bot", OFF, t("st.no_hc"))
elif iso_blocked("bot"):
STATUS.set("bot", OFF, t("st.not_monitored"))
elif not ENABLED["bot"]:
STATUS.set("bot", OFF, t("st.off"))
else:
try:
n = hc.bot_process_count()
except Exception:
n = 0
if n:
STATUS.set("bot", OK, t("st.up"))
else:
STATUS.set("bot", BAD if WANTED["bot"] else OFF,
t("st.down") if WANTED["bot"] else _off_detail("bot")
or t("st.stopped"))
_tunnel_status(hc)
return STATUS.snapshot()
_TUNNEL_CACHE = [0.0, OFF, ""]
def _tunnel_status(hc) -> None:
"""Туннель смотрит туда же, куда `check_tunnel`: `remote-enabled` +
`public-url`. Пробу дорогая (до 12 с), поэтому кэш на 2 цикла опроса."""
now = time.time()
if now - _TUNNEL_CACHE[0] < 24:
STATUS.set("tunnel", _TUNNEL_CACHE[1], _TUNNEL_CACHE[2])
return
if hc is None:
level, detail = OFF, t("st.no_hc")
else:
try:
if hc._cfg_get("remote-enabled").lower() not in ("true", "1"):
level, detail = OFF, t("st.off")
else:
url = hc._cfg_get("public-url")
if not url:
level, detail = WARN, t("st.down")
else:
code = hc._tunnel_probe(url)
level = OK if 0 < code < 500 else (WARN if code >= 500 else BAD)
detail = f"HTTP {code}" if code else t("st.no_answer")
except Exception as e:
level, detail = WARN, f"{type(e).__name__}"
_TUNNEL_CACHE[0], _TUNNEL_CACHE[1], _TUNNEL_CACHE[2] = now, level, detail
STATUS.set("tunnel", level, detail)
def _status_loop(hc, port: int, stack_done: threading.Event,
win_open: threading.Event,
stop: "threading.Event | None" = None,
interval: float = 12.0) -> None:
"""Опрос состояния живёт в своём потоке и переживает и перезагрузку
страницы (панель пересоздаётся по id), и отсутствие окна (браузерный
фолбэк): тогда просто некому рисовать. Пауза читается из `stop`, а не из
`win_open`: снятый флаг спящий поток не разбудил бы, и панель доживала бы до
конца цикла уже после выхода из программы."""
while win_open.is_set():
try:
snap = refresh_status(hc, port, stack_done)
icon = TRAY_ICON
if icon is not None:
try:
icon.update_menu() # тексты пунктов — колбэки, перечитаются
except Exception:
pass
push_overlay(WINDOW, UI_PREFS["overlay"], snap)
except Exception as e:
_log(f"[launcher] status loop: {type(e).__name__}: {e}")
if stop is not None:
stop.wait(interval)
else:
win_open.wait(interval)
_log("[launcher] status loop stopped")
# ── Tray icon + minimize-to-tray ───────────────────────────────────────────────
# The window's close (X) and minimize both fold the app into the system tray
# instead of quitting — the server keeps running so downloads continue. The tray
# icon's left-click restores the window; "Выход" really quits. Gated by the
# config key `minimize-to-tray` (default ON). All of this is best-effort: any
# failure degrades to plain close/quit and is logged, never crashes the launcher.
UI_PREFS = {"overlay": True, "panel_open": True} # панель; переключаются пунктом в трее
WINDOW = None # pywebview-окно, ставит open_window — в него рисуется панель
PANEL_WIN = None # отдельное OS-окно мобильной панели (трекер #37); hide, не destroy
PANEL_PREFS = {"on_top": False} # прибитость поверх всех окон — кнопкой 📌 в панели
APP_URL = "" # база адреса приложения, ставит open_window: из неё строится url панели
TRAY_ICON = None # pystray Icon — по нему обновляются пункты меню
LOCK_FILE = None # set in main(); single-instance lock holding our PID
SHOW_FLAG = None # a second launch drops this so the running instance pops up
RPWIN_REQ = None # POST /api/player-window/open drops this → show the panel OS-window
RPWIN_ACK = None # we write the result back so the server stops waiting (see _watch_show_flag)
WIN_STATE = None # set in main(); remembers the window's last position + size
def _load_win_state() -> dict:
"""Last-saved window geometry {x,y,width,height}, sanity-checked. Empty dict
→ use defaults (pywebview centers a 1280×860 window)."""
import json
try:
if WIN_STATE and WIN_STATE.exists():
d = json.loads(WIN_STATE.read_text(encoding="utf-8"))
w, h = d.get("width"), d.get("height")
if isinstance(w, int) and isinstance(h, int) and 480 <= w <= 10000 and 360 <= h <= 10000:
out = {"width": w, "height": h}
x, y = d.get("x"), d.get("y")
# Guard against off-screen coords (unplugged monitor etc).
if isinstance(x, int) and isinstance(y, int) and -200 <= x <= 20000 and -200 <= y <= 20000:
out["x"], out["y"] = x, y
return out
except Exception as e:
_log(f"[launcher] window-state load skipped: {type(e).__name__}: {e}")
return {}
def _save_win_state(geo: dict) -> None:
try:
if WIN_STATE:
cur = {}
if WIN_STATE.exists():
try:
cur = json.loads(WIN_STATE.read_text(encoding="utf-8")) or {}
except Exception:
cur = {}
cur.update(geo) # prefs (overlay) живут в том же файле — не затирать
WIN_STATE.write_text(json.dumps(cur), encoding="utf-8")
except Exception:
pass
def _ui_pref_file() -> "Path | None":
return WIN_STATE
def load_ui_prefs() -> None:
try:
f = _ui_pref_file()
if f and f.exists():
d = json.loads(f.read_text(encoding="utf-8"))
if isinstance(d.get("overlay"), bool):
UI_PREFS["overlay"] = d["overlay"]
if isinstance(d.get("panel_open"), bool):
UI_PREFS["panel_open"] = d["panel_open"]
comp = d.get("components")
if isinstance(comp, dict):
for k in COMPONENTS:
if isinstance(comp.get(k), bool):
ENABLED[k] = comp[k]
except Exception:
pass
def save_ui_prefs() -> None:
"""Пишем рядом с геометрией окна, но только свои ключи: файл общий, и
_save_win_state перезаписал бы его целиком без панели."""
try:
f = _ui_pref_file()
if not f:
return
f.parent.mkdir(parents=True, exist_ok=True)
d = {}
if f.exists():
try:
d = json.loads(f.read_text(encoding="utf-8")) or {}
except Exception:
d = {}
d["overlay"] = UI_PREFS["overlay"] # геометрию не затираем — файл общий
d["panel_open"] = UI_PREFS["panel_open"]
d["components"] = {k: ENABLED[k] for k in COMPONENTS}
f.write_text(json.dumps(d), encoding="utf-8")
except Exception:
pass
# ── Мобильная панель: отдельное OS-окно (трекер #37) ─────────────────────────
#
# Почему окно, а не `<div>` внутри Ripster: панель обязана жить при свёрнутой
# программе — DOM главного окна умирает вместе со скрытием в трей. И не
# Document-PiP/window.open, как учил старый `floatPlayer()`: оба задушены в
# WebView2 без всякого предупреждения. Окно, созданное Python-ом, этим не
# ограничено.
#
# Always-on-top по умолчанию ВЫКЛ: это целый интерфейс (~420×800), а не
# часовик; прибитое поверх всех окон розовое окно мешало бы игре и работе.
# Требование «переживает сворачивание Ripster» выполняет само существование
# отдельного окна. Пристегивается кнопкой 📌 в шапке панели, выбор запоминается.
#
# Крестик окна = hide, не destroy: повторный ⧉ открывает мгновенно и со своим
# состоянием, а звук вообще не трогается — плеер живёт в главном окне, панель
# только контроллер. Реальный выход (do_quit) обязан погасить панель:
# webview.start() ждёт закрытия ВСЕХ окон, иначе приложение не выйдет.
PANEL_DEFAULTS = {"width": 420, "height": 800}
def _load_panel_state() -> dict:
"""{geo:{x,y,width,height}, on_top:bool} из общего файла состояния окна."""
import json
out = {"geo": {}, "on_top": PANEL_PREFS["on_top"]}
try:
if WIN_STATE and WIN_STATE.exists():
d = json.loads(WIN_STATE.read_text(encoding="utf-8")) or {}
g = d.get("panel_geo")
if isinstance(g, dict):
w, h = g.get("width"), g.get("height")
if isinstance(w, int) and isinstance(h, int) and 280 <= w <= 4000 and 400 <= h <= 4000:
out["geo"] = {"width": w, "height": h}
x, y = g.get("x"), g.get("y")
if isinstance(x, int) and isinstance(y, int) and -200 <= x <= 20000 and -200 <= y <= 20000:
out["geo"]["x"], out["geo"]["y"] = x, y
if isinstance(d.get("panel_on_top"), bool):
out["on_top"] = d["panel_on_top"]
except Exception as e:
_log(f"[panel-win] state load skipped: {type(e).__name__}: {e}")
return out
def _save_panel_state(geo: dict = None, on_top: bool = None) -> None:
"""Только свои ключи: файл общий с геометрией главного окна и UI_PREFS."""
import json
try:
f = WIN_STATE
if not f:
return
f.parent.mkdir(parents=True, exist_ok=True)
d = {}
if f.exists():
try:
d = json.loads(f.read_text(encoding="utf-8")) or {}
except Exception:
d = {}
if geo is not None:
d["panel_geo"] = geo
if on_top is not None:
d["panel_on_top"] = bool(on_top)
f.write_text(json.dumps(d), encoding="utf-8")
except Exception:
pass
def _panel_eval(js: str) -> None:
"""evaluate_js в панельное окно, если оно живое."""
try:
if PANEL_WIN is not None:
PANEL_WIN.evaluate_js(js)
except Exception as e:
_log(f"[panel-win] eval failed: {type(e).__name__}: {e}")
def _panel_host_say(kind: str) -> None:
"""Сказать главному окну о судьбе панели, когда OS-события молчат.
SW_HIDE/SW_SHOW не меняют document.hidden в WebView2, поэтому panel.js со
своим visibilitychange молчит в обе стороны: хост считает спрятанную панель
живой (и следующий клик вместо показа прячет её снова), а показанной не
верит. Python знает фактическую видимость — он и передаёт рукопожатие.
evaluate_js ждёт ответ страницы и НИКОГДА не вызывается с GUI-потока:
closing-обработчик живёт именно на нём, и синхронный вызов отсюда вешает
весь оконный цикл webview (доказано живьём 22.09: «host say» не
возвращался, мост умирал, тики останавливались). Отправляем в свой поток."""
def go():
try:
if WINDOW is None or not CTX.get("win_open"):
_log(f"[panel-win] host say({kind}) SKIPPED: "
f"window={'нет' if WINDOW is None else 'есть'} "
f"win_open={bool(CTX.get('win_open'))}")
return
js = ("rpHostRecv({msg}); (typeof RP==='undefined') ? 'no-RP' : "
"(RP.open + '|' + RP.ready + '|' + RP.transport)").format(
msg=json.dumps(json.dumps({"rp": 1, "k": kind})))
res = WINDOW.evaluate_js(js)
_log(f"[panel-win] host say({kind}) → {res}")
except Exception as e:
_log(f"[panel-win] host say({kind}) failed: {type(e).__name__}: {e}")
try:
threading.Thread(target=go, daemon=True).start()
except Exception as e:
_log(f"[panel-win] host say({kind}) thread failed: {type(e).__name__}: {e}")
class PanelApi:
"""js_api окна панели. Мост панель↔главное окно идёт через Python:
host() перекладывает сообщения в WINDOW.rpHostRecv(), хост отвечает через
LauncherApi.panel_push → rpRecv(). Никакого сырого подстава: payload
проходит json.loads/dumps с обеих сторон."""
def host(self, payload):
try:
d = json.loads(str(payload)) # валидируем, что это вообще JSON
except Exception as e:
_log(f"[panel-win] bad host payload: {e}")
return "bad"
if not CTX.get("win_open") or WINDOW is None:
return "no-host" # главное окно ещё/уже не с нами
try:
WINDOW.evaluate_js(f"rpHostRecv({json.dumps(json.dumps(d, ensure_ascii=False))})")
return "ok"
except Exception as e:
_log(f"[panel-win] host forward failed: {type(e).__name__}: {e}")
return "fail"
def pin(self, on):
global PANEL_PREFS
on = bool(on)
PANEL_PREFS["on_top"] = on
_save_panel_state(on_top=on)
try:
if PANEL_WIN is not None:
PANEL_WIN.on_top = on
except Exception as e:
_log(f"[panel-win] pin failed: {type(e).__name__}: {e}")
return "ok"
def hide(self):
try:
if PANEL_WIN is not None:
PANEL_WIN.hide()
_panel_host_say("bye")
except Exception:
pass
return "ok"
def _panel_destroy() -> None:
"""Реальный выход: снять панель до уничтожения главного окна."""
global PANEL_WIN
try:
if PANEL_WIN is not None:
w, PANEL_WIN = PANEL_WIN, None
# closing-обработчик окна прячет его от любого закрытия; снимаем
# его только на время этого destroy — иначе выход не состоится.
try:
w._rp_quitting = True
except Exception:
pass
w.destroy()
except Exception as e:
_log(f"[panel-win] destroy failed: {type(e).__name__}: {e}")
def _panel_toggle(show=None) -> str:
"""Создать (первый раз) / показать / спрятать окно панели.
show=None — переключить; True/False — задать явно. Возвращает 'window'
(хост обязан отказаться от iframe-дока) или 'fail' (тогда док уместен)."""
global PANEL_WIN
try:
import webview
if PANEL_WIN is None:
st = _load_panel_state()
PANEL_PREFS["on_top"] = st["on_top"]
geo = {**PANEL_DEFAULTS, **st["geo"]}
url = (APP_URL or "http://127.0.0.1:8000").rstrip("/") + \
"/static/panel/index.html?rpwin=1"
win = webview.create_window(
"Ripster — панель", url=url, js_api=PanelApi(),
width=geo["width"], height=geo["height"],
x=geo.get("x"), y=geo.get("y"),
on_top=PANEL_PREFS["on_top"], resizable=True, min_size=(340, 520))
_geo = dict(geo)
_tmr = [None]
def _sched():
try:
if _tmr[0]:
_tmr[0].cancel()
t = threading.Timer(1.0, lambda: _save_panel_state(geo=dict(_geo)))
t.daemon = True
t.start()
_tmr[0] = t
except Exception:
pass
def _resized(w_, h_):
try:
_geo["width"], _geo["height"] = int(w_), int(h_)
_sched()
except Exception:
pass
def _moved(x_, y_):
try:
_geo["x"], _geo["y"] = int(x_), int(y_)
_sched()
except Exception:
pass
def _closing():
# Крестик = спрятать. Убить панель может только реальный выход.
# Но destroy() тоже проходит через этот обработчик: без флага
# он отменил бы выход, и webview завис бы в ожидании ЗАКРЫТИЯ
# ВСЕХ окон (тот самый «реальный выход» в проверке 4f).
if getattr(win, "_rp_quitting", False):
_log("[panel-win] closing → destroy (реальный выход)")
return True
_log("[panel-win] closing → hide (крестик)")
try:
win.hide()
except Exception as e:
_log(f"[panel-win] hide-on-close failed: {type(e).__name__}: {e}")
_panel_host_say("bye")
return False
win.events.resized += _resized
win.events.moved += _moved
win.events.closing += _closing
PANEL_WIN = win
_log(f"[panel-win] окно создано {geo} on_top={PANEL_PREFS['on_top']}")
return "window"
try:
visible = bool(PANEL_WIN.visible)
except Exception:
visible = False
if show is None:
show = not visible
if show:
PANEL_WIN.show()
try:
PANEL_WIN.restore()
except Exception:
pass
try:
PANEL_WIN.focus()
except Exception:
pass
_panel_host_say("hello")
else:
PANEL_WIN.hide()
_panel_host_say("bye")
return "window"
except Exception as e:
import traceback
_log(f"[panel-win] FAILED ({type(e).__name__}: {e}) — хосту сигнал док-режим")
_log(traceback.format_exc())
return "fail"
def tray_enabled() -> bool:
"""Whether the tray icon exists at all. When True, closing the window (X)
folds the app into the tray so downloads keep running; when False, closing
really quits. config.yaml `minimize-to-tray` (default True). Env override for
testing. NOTE: this no longer controls what a plain MINIMIZE does — see
minimize_to_tray() for that (default: stay on the taskbar)."""
env = os.environ.get("RIPSTER_TRAY")
if env is not None:
return env.strip() not in ("0", "false", "False", "")
try:
import yaml
c = yaml.safe_load((BASE / "config.yaml").read_text(encoding="utf-8")) or {}
return bool(c.get("minimize-to-tray", True))
except Exception:
return True
def minimize_to_tray() -> bool:
"""Where a plain MINIMIZE (the _ button) sends the window.
Default is the taskbar — a normal minimize that stays visible on the
taskbar, which is what users expect (testers reported the app "disappearing"
from the taskbar on minimize). Set config.yaml `minimize-to: tray` to fold
minimizes into the system tray instead. Env override RIPSTER_MINIMIZE_TRAY."""
env = os.environ.get("RIPSTER_MINIMIZE_TRAY")
if env is not None:
return env.strip() not in ("0", "false", "False", "")
try:
import yaml
c = yaml.safe_load((BASE / "config.yaml").read_text(encoding="utf-8")) or {}
return str(c.get("minimize-to", "taskbar")).strip().lower() == "tray"
except Exception:
return False
def apply_webview_acceleration() -> str:
"""Разогнать окно программы средствами самого движка WebView2.
Окно Ripster — это встроенный Chromium (WebView2), и по умолчанию Windows
делает с ним две неприятные вещи, которые выглядят как «тормозит наш код»:
1. **Расчёт перекрытия окна.** Когда сверху лежит другое полноэкранное
окно (у владельца это игра), Chromium считает наше окно невидимым и
перестаёт рисовать. Возврат к Ripster показывает застывший кадр, пока
страница не перерисуется, а анимации в этот момент «прыгают».
2. **Торможение фоновых таймеров.** Свёрнутому/перекрытому окну Chromium
режет таймеры до одного тика в секунду — а на них держатся прогресс
очереди, плеер и опрос WebSocket.
Плюс включаем растеризацию и композитинг на видеокарте: у сеток обложек
это самая тяжёлая часть кадра.
Ничего не зашито намертво. Порядок: env `RIPSTER_WEBVIEW_ARGS` (польностью
заменяет строку) → config.yaml `webview-browser-args` → набор по умолчанию.
Отдельный выключатель `webview-hw-accel: false` (env `RIPSTER_HW_ACCEL=0`)
переводит окно на программную отрисовку — это путь отхода для машин, где
драйвер видеокарты бракованный и ускорение даёт чёрный экран.
Возвращает итоговую строку аргументов (для лога)."""
# Аргументы читает сам WebView2 при создании браузерного процесса, поэтому
# переменную надо выставить ДО создания окна — позже она уже ни на что не влияет.
env_args = os.environ.get("RIPSTER_WEBVIEW_ARGS")
if env_args is not None:
args = env_args.strip()
os.environ["WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS"] = args
return args
cfg = {}
try:
import yaml
cfg = yaml.safe_load((BASE / "config.yaml").read_text(encoding="utf-8")) or {}
except Exception:
pass
accel_env = os.environ.get("RIPSTER_HW_ACCEL")
if accel_env is not None:
accel = accel_env.strip() not in ("0", "false", "False", "")
else:
accel = bool(cfg.get("webview-hw-accel", True))
override = str(cfg.get("webview-browser-args", "") or "").strip()
if override:
args = override
elif accel:
args = " ".join([
# Не переставать рисовать, когда окно перекрыто игрой или другим окном.
"--disable-features=CalculateNativeWinOcclusion",
"--disable-backgrounding-occluded-windows",
# Держать таймеры живыми: на них прогресс очереди, плеер и WebSocket.
"--disable-background-timer-throttling",
"--disable-renderer-backgrounding",
# Растеризация и композитинг силами видеокарты.
"--enable-gpu-rasterization",
"--enable-zero-copy",
])
else:
# Явный отказ от ускорения — окно рисуется процессором.
args = "--disable-gpu --disable-gpu-compositing"
os.environ["WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS"] = args
return args
def _tray_image():
"""A PIL image for the tray icon — the shipped ripster.ico, or a drawn dot."""
try:
from PIL import Image
ico = BASE / "ripster.ico"
if ico.exists():
return Image.open(str(ico))
except Exception as e:
_log(f"[launcher] tray icon image fallback: {type(e).__name__}: {e}")
try:
from PIL import Image, ImageDraw
img = Image.new("RGBA", (64, 64), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
d.ellipse((8, 8, 56, 56), fill=(124, 92, 255, 255))
return img
except Exception:
return None
def start_tray(window, do_quit) -> "object | None":
"""Start a detached system-tray icon. Left-click / "Открыть" restores the
window; "Выход" calls do_quit(). Returns the pystray Icon or None.
Меню — не украшение, а второй экземпляр панели состояния: тексты пунктов это
колбэки, поэтому `icon.update_menu()` (его зовёт поток опроса) держит в трее
те же 🟢/🟡/🔴 и то же время перезапуска, что и полоса в окне. Галочка
«Запускать при входе» читает состояние задачи Планировщика, а не своё
представление о нём: задача могла быть удалена или отключена вручную."""
try:
import pystray
except Exception as e:
_log(f"[launcher] pystray unavailable ({type(e).__name__}: {e}) — no tray")
return None
img = _tray_image()
if img is None:
_log("[launcher] no tray image — no tray")
return None
def _restore(icon=None, item=None):
try:
window.show()
window.restore()
except Exception as e:
_log(f"[launcher] tray restore failed: {type(e).__name__}: {e}")
def _quit(icon=None, item=None):
try:
do_quit()
except Exception as e:
_log(f"[launcher] tray quit failed: {type(e).__name__}: {e}")
def _notify(icon, msg):
try:
icon.notify(msg, t("panel.title"))
except Exception:
pass
def _toggle_autostart(icon=None, item=None):
want = not autostart_enabled()
ok, err = autostart_set_enabled(want)
_notify(icon, t("tray.autostart_on") if ok and want else
(t("tray.autostart_off") if ok else
t("tray.autostart_err", err=(err or "PowerShell")))[:250])
def _toggle_overlay(icon=None, item=None):
UI_PREFS["overlay"] = not UI_PREFS["overlay"]
save_ui_prefs()
push_overlay(window, UI_PREFS["overlay"], STATUS.snapshot())
try:
menu = pystray.Menu(
pystray.MenuItem(t("tray.open"), _restore, default=True),
pystray.Menu.SEPARATOR,
pystray.MenuItem(lambda i: t("tray.status_title"), None, enabled=False),
*[pystray.MenuItem(lambda i, k=k: " " + STATUS.line(k), None, enabled=False)
for k in Status.KEYS],
pystray.Menu.SEPARATOR,
pystray.MenuItem(t("tray.autostart"), _toggle_autostart,
checked=lambda i: autostart_enabled()),
pystray.MenuItem(t("tray.overlay"), _toggle_overlay,
checked=lambda i: UI_PREFS["overlay"]),
pystray.Menu.SEPARATOR,
pystray.MenuItem(t("tray.quit"), _quit),
)
icon = pystray.Icon("ripster", img, "Ripster", menu)
icon.run_detached() # spins its own thread; returns immediately
global TRAY_ICON
TRAY_ICON = icon
_log("[launcher] tray icon started")
return icon
except Exception as e:
import traceback
_log(f"[launcher] tray start failed: {type(e).__name__}: {e}")
_log(traceback.format_exc())
return None
def _rpwin_poll_once() -> bool:
"""Один шаг протокола «сервер просит окно панели» (кнопка «Открыть
внешний плеер», POST /api/player-window/open). Сервер ждёт ACK недолго:
старый frozen-exe протокола не знает, и тогда он честно заводит
standalone-окно (ripster.player_window), а не вешает кнопку."""
try:
if RPWIN_REQ and RPWIN_REQ.exists():
RPWIN_REQ.unlink()
res = _panel_toggle(show=True)
try:
if RPWIN_ACK:
RPWIN_ACK.parent.mkdir(parents=True, exist_ok=True)
RPWIN_ACK.write_text(res, encoding="utf-8")
except Exception:
pass
_log(f"[launcher] server rpwin request → panel ({res})")
return True
except Exception as e:
_log(f"[launcher] rpwin poll: {type(e).__name__}: {e}")
return False
def _watch_show_flag(window, win_open) -> None:
"""Poll for the SHOW_FLAG a second launch drops, and surface the window."""
while win_open.is_set():
try:
if SHOW_FLAG and SHOW_FLAG.exists():
SHOW_FLAG.unlink()
window.show()
window.restore()
_log("[launcher] second launch → surfaced window")
except Exception:
pass
_rpwin_poll_once()
time.sleep(0.7)
def _splash_html(msg: str, detail: str) -> str:
"""Static splash shell (pulsing dots + message), no network calls of its
own. A page loaded via webview's html= gets a null/opaque origin, and a
fetch() from there to 127.0.0.1 gets blocked outright by Chromium (CORS
AND no-cors both fail — this isn't a CORS-allowlist problem, something
more fundamental blocks private-network fetches from an opaque origin).
So readiness is polled from PYTHON (open_window's background thread,
plain urlopen — no browser involved) which calls window.load_url()/
load_html() once it knows the answer, instead of asking the page to
figure it out itself."""
return f"""<!doctype html><html><head><meta charset="utf-8">
<style>
html,body{{height:100%;margin:0;background:#0a0a0c;color:#f0f0f4;
font-family:-apple-system,Segoe UI,sans-serif;display:flex;
align-items:center;justify-content:center}}
.wrap{{text-align:center;max-width:420px;padding:20px}}
.dot{{width:10px;height:10px;border-radius:50%;background:#c084a0;
display:inline-block;margin:0 3px;animation:pulse 1.2s ease-in-out infinite}}
.dot:nth-child(2){{animation-delay:.2s}} .dot:nth-child(3){{animation-delay:.4s}}
@keyframes pulse{{0%,80%,100%{{opacity:.25;transform:scale(.8)}}40%{{opacity:1;transform:scale(1)}}}}
h3{{font-weight:600;margin:18px 0 6px}}
p{{color:#9a9aa4;font-size:12px;line-height:1.6;margin:4px 0}}
code{{background:#1a1a20;padding:1px 5px;border-radius:4px}}
</style></head>
<body><div class="wrap">
<div><span class="dot"></span><span class="dot"></span><span class="dot"></span></div>
<h3>{msg}</h3>
<p>{detail}</p>
</div></body></html>"""
def _starting_html() -> str:
return _splash_html("Запускаю Ripster…",
"Первый запуск может занять минуту — антивирус проверяет файлы.")
def _still_starting_html() -> str:
return _splash_html("Запускаю Ripster…",
"Всё ещё запускается — это нормально на первом старте.")
def _not_responding_html() -> str:
return _splash_html(
"Сервер не отвечает",
"Проверь <code>logs\\\\launcher.log</code> и <code>logs\\\\console.log</code> в папке установки.<br>"
"Частая причина — антивирус блокирует <code>python\\\\python.exe</code>.")
# ── WebView2 runtime gate ────────────────────────────────────────────────────
# Окно Ripster рендерит `static/index.html`, а он использует современный JS.
# На доисторическом WebView2 скрипт падает ДО первого рендера → пользователь
# видит пустое белое окно без единого слова (жалоба yueka 02.09.2026: убил два
# часа, нашёл причину — старый WebView2 — сам). Ниже — явная проверка версии
# рантайма ДО создания окна; если она заведомо старая, показываем простую
# HTML-страницу (без модерного JS, отрисуется на любом Chromium) с прямой
# ссылкой на установщик, вместо белого окна.
_WEBVIEW2_MIN_MAJOR = 90 # ~Chromium 90 (апрель 2021); evergreen-рантайм давно выше
def _webview2_runtime_version() -> str:
"""Версия установленного Microsoft Edge WebView2 Runtime (`"122.0.2365.66"`)
или "" если не найдена. GUID {F3017226-…} — это именно рантайм WebView2."""
try:
import winreg
except Exception:
return ""
guid = "{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}"
for root, sub in (
(winreg.HKEY_LOCAL_MACHINE, rf"SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{guid}"),
(winreg.HKEY_LOCAL_MACHINE, rf"SOFTWARE\Microsoft\EdgeUpdate\Clients\{guid}"),
(winreg.HKEY_CURRENT_USER, rf"SOFTWARE\Microsoft\EdgeUpdate\Clients\{guid}"),
):
try:
with winreg.OpenKey(root, sub) as k:
val, _ = winreg.QueryValueEx(k, "pv")
if val and val != "0.0.0.0":
return str(val)
except OSError:
continue
return ""
def _webview2_ok() -> tuple[bool, str]:
"""(годен ли рантаём, найденная версия). Не смогли прочитать версию —
считаем годным (не блокируем рабочую установку из-за нестандартного
размещения ключа)."""
ver = _webview2_runtime_version()
if not ver:
return True, ""
try:
major = int(ver.split(".", 1)[0])
except ValueError:
return True, ver
return major >= _WEBVIEW2_MIN_MAJOR, ver
def _webview2_too_old_html(cur: str) -> str:
dl = "https://developer.microsoft.com/en-us/microsoft-edge/webview2/"
return f"""<!doctype html><html><head><meta charset="utf-8">
<style>
html,body{{height:100%;margin:0;background:#0a0a0c;color:#f0f0f4;
font-family:-apple-system,Segoe UI,Arial,sans-serif;display:flex;
align-items:center;justify-content:center}}
.wrap{{max-width:460px;padding:28px;text-align:center;line-height:1.6}}
h2{{font-weight:600;margin:0 0 10px}}
p{{color:#b8b8c0;font-size:13px;margin:8px 0}}
a.btn{{display:inline-block;margin-top:16px;padding:10px 20px;border-radius:8px;
background:#c084a0;color:#0a0a0c;text-decoration:none;font-weight:600;font-size:13px}}
code{{background:#1a1a20;padding:1px 5px;border-radius:4px}}
</style></head><body><div class="wrap">
<h2>Нужно обновить Microsoft Edge WebView2</h2>
<p>Ripster работает на движке WebView2 версии {_WEBVIEW2_MIN_MAJOR}+, а у вас установлена <code>{cur or "неизвестно / отсутствует"}</code>.
Без обновления окно останется пустым.</p>
<p><b>Update Microsoft Edge WebView2</b> — Ripster needs WebView2 runtime {_WEBVIEW2_MIN_MAJOR}+, yours is <code>{cur or "missing"}</code>.</p>
<a class="btn" href="{dl}">Скачать / Download WebView2</a>
<p style="margin-top:18px;font-size:11px">Возьмите «Evergreen Standalone Installer», поставьте, перезапустите Ripster.</p>
</div></body></html>"""
def _poll_until_ready(window, url: str, port: int) -> None:
"""Background thread: keep checking readiness with a plain Python HTTP
call (ripster_alive — no browser, no CORS/origin issues) and drive the
ALREADY-OPEN window over to the real URL once it answers, updating the
splash message if it's taking a while. Runs only when open_window()
couldn't confirm readiness before creating the window."""
import time as _t
if not ENABLED.get("server"):
# Сервер выключен чекбоксом лончера: крутить пробник бессмысленно —
# страница «Сервер выключен» честная, панель поверх неё остаётся живой.
try:
window.load_html(_splash_html(t("splash.server_off_t"),
t("splash.server_off_d")))
except Exception:
pass
return
start = _t.time()
told_slow = False
while _t.time() - start < 180:
if ripster_alive(port):
_leave_splash()
return
if not told_slow and _t.time() - start > 15:
told_slow = True
try:
window.load_html(_still_starting_html())
except Exception:
pass
_t.sleep(1.0)
try:
window.load_html(_not_responding_html())
SPLASH["active"] = False
except Exception:
pass
_log(f"[launcher] server never answered on 127.0.0.1:{port} within the extended splash window")
# ── Мост панель → лончер (pywebview js_api) ──────────────────────────────────
# DOM-кнопки ничего не решают: они зовут эти методы, а весь смысл — в состоянии
# Python (ENABLED/WANTED/BUSY), которое после каждого вызова перерисовывает
# панель. Долгих вызовов наружу нет: действие уходит в поток и отвечает сразу.
class LauncherApi:
# ЧЕРЕПНОЕ подчёркивание обязательно: pywebview обходит dir() всего js_api
# и рекурсивно лезет в публичные не-вызываемые атрибуты. С публичным
# `window` он добирается до native (WinForms/.NET) и зависает/ломается на
# его обходе — из-за этого window.pywebview.api переставал появляться,
# и весь мост окна (кнопки оверлея, панель) молча отваливался.
_window = None # навешивается после create_window: create_file_dialog живёт на окне
def pick_folder(self, directory=""):
"""Нативный выбор папки (Tracker #9014): возвращает НАСТОЯЩИЕ пути,
которые бэкенд умеет сканировать сам. None в браузере (там нет моста) —
фронт по этому отличает оболочку от.bat-режима. Вызов блокирующий:
модальный диалог держит js-поток, окно отвечает (диалог — своё окно).
Свой try/except обязателен: без него отмена диалога прилетает в консоль
как необработанное исключение моста."""
try:
import webview
w = self._window
if w is None:
return None
paths = w.create_file_dialog(webview.FOLDER, directory=str(directory or ""),
allow_multiple=True)
return list(paths) if paths else []
except Exception as e:
_log(f"[panel] pick_folder: {type(e).__name__}: {e}")
return None
def action(self, name, key=""):
name, key = str(name), str(key)
if not CTX.get("win_open"):
return "ok" # окно ещё не открыто — из main() пути нет, но и crash не нужен
_log(f"[panel] клик: {name} {key}".rstrip())
if name == "panel":
UI_PREFS["panel_open"] = not UI_PREFS.get("panel_open", True)
save_ui_prefs()
render_panel()
return "ok"
if name == "start_all":
so = CTX.get("stack_done")
if so is not None and not so.is_set():
return "ok" # стартовый подъём ещё идёт — не плодим второй
_spawn(lambda: bring_up_stack(CTX["win_open"], so))
return "ok"
if key in COMPONENTS:
if not CTX.get("win_open", threading.Event()).is_set():
return "ok"
if name == "start" and (not ENABLED[key] or iso_blocked(key)):
return "ok"
_spawn(lambda: _component_action(key, name == "start"))
return "ok"
return "bad"
def pref(self, name, key, on):
name, key = str(name), str(key)
on = bool(on)
_log(f"[panel] переключатель: {name} {key} → {on}")
if name == "enabled" and key in COMPONENTS:
ENABLED[key] = on
if not on:
WANTED[key] = False # с checkbox-а надзор снимается сразу
save_ui_prefs()
elif name == "autostart":
ok, err = autostart_set_enabled(on)
if not ok:
_log(f"[panel] autostart failed: {err}")
render_panel()
return "ok"
# ── мобильная панель (трекер #37): главный окно → OS-окно панели ─────────
def mobile_panel(self, toggle="1"):
"""Зовёт ⧉ в баре/сайдбаре. 'window' — OS-окно открыто/показано,
iframe-док не нужен; 'fail' — хост честно откатывается на док.
toggle='1' — переключить видимость, '0' — спрятать."""
return _panel_toggle(show=None if str(toggle) == "1" else False)
def main_show(self):
"""Поднять главное окно: панель велела открыть вид (digs/bbc/settings),
а окно может быть спрятано в трей — показывать нечего."""
try:
if WINDOW is not None:
WINDOW.show()
WINDOW.restore()
except Exception:
pass
return "ok"
def panel_push(self, payload):
"""Состояние/команды хоста → окну панели. Payload — строка JSON,
окно получает её как rpRecv(<str>) и парсит у себя."""
_panel_eval(f"rpRecv({json.dumps(str(payload))})")
return "ok"
def open_window(url: str, port: int, win_open, title: str = "Ripster", ready: bool = False):
"""Native pywebview window with tray + minimize-to-tray; browser fallback.
Returns ('webview', state) | ('browser', None). `state['quit']` is True only
when the user really chose Выход (so main() tears the server down).
`ready`: caller already confirmed (via wait_for_ripster, a plain Python
HTTP check) that the server answers — the common case, since it binds in
a few seconds. Then we point the window straight at the real URL, same
as before the splash existed. Only when the server hasn't answered yet
(a genuine cold start) do we show a static splash and hand off to a
background Python thread that polls and navigates the window once ready
— see _poll_until_ready. A client-side fetch() from the splash can't do
this reliably: it runs from a null/opaque origin and browsers block its
requests to 127.0.0.1 outright, independent of the server's own CORS
config (learned the hard way — v3.0.30/31 shipped that broken)."""
try:
import threading
# Строго ДО импорта/старта webview: WebView2 читает аргументы из окружения
# в момент создания браузерного процесса, позже они уже не действуют.
try:
_log(f"[launcher] webview args: {apply_webview_acceleration()}")
except Exception as _e:
_log(f"[launcher] webview args skipped: {_e}")
import webview
_wv_ok, _wv_ver = _webview2_ok()
if not _wv_ok:
_log(f"[launcher] WebView2 runtime too old ({_wv_ver} < {_WEBVIEW2_MIN_MAJOR}) — showing update page instead of blank window")
_log(f"[launcher] opening webview window -> {url} (ready={ready}, wv2={_wv_ver or '?'})")
geo = _load_win_state()
window = webview.create_window(
title,
**({"html": _webview2_too_old_html(_wv_ver)} if not _wv_ok
else {"url": url} if ready else {"html": _starting_html()}),
js_api=LauncherApi(), # кнопки/чекбоксы панели приходят в LauncherApi
width=geo.get("width", 1280), height=geo.get("height", 860),
x=geo.get("x"), y=geo.get("y"))
if _wv_ok and not ready:
SPLASH.update(active=True, url=url)
threading.Thread(target=_poll_until_ready, args=(window, url, port), daemon=True).start()
state = {"quit": False, "tray": None, "notified": False}
min_to_tray = minimize_to_tray()
use_tray = tray_enabled()
global WINDOW, APP_URL
WINDOW = window
APP_URL = url # из неё строится адрес окна панели (_panel_toggle)
try:
LauncherApi._window = window # для create_file_dialog из страницы (pick_folder)
except Exception as _e:
_log(f"[launcher] pick_folder bridge unavailable: {_e}")
load_ui_prefs()
_log(f"[launcher] панель: «запустить всё»+'{t('panel.start_all')}', чекбоксы="
+ ",".join(f"{k}:{'вкл' if ENABLED[k] else 'выкл'}" for k in COMPONENTS))
# страница перезагружается (Ctrl+R, load_url после splash) — панель уезжает
# вместе с DOM, вешаем её обратно на каждый loaded
try:
# pywebview передаёт окно обработчику только если его первый
# аргумент назван «window» (webview/event.py); «w» молча ловит
# TypeError, и перезагрузка (Ctrl+R, splash→load_url) лишала
# оверлей — панель отъезжала обратно в iframe-док.
window.events.loaded += lambda window: push_overlay(
window, UI_PREFS["overlay"], STATUS.snapshot())
except Exception as e:
_log(f"[launcher] overlay reload hook unavailable: {type(e).__name__}: {e}")
# ЗДЕСЬ синхронного push_overlay быть не должно: до webview.start() окно
# не запущено, evaluate_js ждёт сигнала от страницы ровно 20 секунд и
# гарантированно падает («Main window failed to start») — это был
# двадцатисекундный провис на каждом холодном старте. Первый рисунок
# приносит hook loaded выше, он срабатывает и на первой загрузке.
# Remember where/how big the user left the window and restore it next launch.
_geo = {"width": geo.get("width", 1280), "height": geo.get("height", 860)}
if "x" in geo: _geo["x"] = geo["x"]
if "y" in geo: _geo["y"] = geo["y"]
_geo_timer = [None]
def _schedule_geo_save():
try:
if _geo_timer[0]:
_geo_timer[0].cancel()
tmr = threading.Timer(1.0, lambda: _save_win_state(dict(_geo)))
tmr.daemon = True
tmr.start()
_geo_timer[0] = tmr
except Exception:
pass
def on_resized(w, h):
try:
_geo["width"], _geo["height"] = int(w), int(h)
_schedule_geo_save()
except Exception:
pass
def on_moved(x, y):
try:
_geo["x"], _geo["y"] = int(x), int(y)
_schedule_geo_save()
except Exception:
pass
def do_quit():
state["quit"] = True
_panel_destroy() # webview ждёт закрытия ВСЕХ окон: не погасить панель — выход зависнет
try:
_save_win_state(dict(_geo)) # persist final geometry on real quit
except Exception:
pass
try:
if state["tray"] is not None:
state["tray"].stop()
except Exception:
pass
try:
window.destroy()
except Exception:
pass
def on_closing():
# Real quit (from tray) → allow the close. Otherwise fold to tray.
if state["quit"] or not use_tray or state["tray"] is None:
_panel_destroy() # главное окно гаснет по-настоящему — панель с ним
return True
try:
window.hide()
if not state["notified"]:
state["notified"] = True
try:
state["tray"].notify(
"Ripster свёрнут в трей — загрузки продолжаются. "
"Клик по значку откроет окно, «Выход» закроет программу.",
"Ripster")
except Exception:
pass
except Exception as e:
_log(f"[launcher] hide-to-tray failed: {type(e).__name__}: {e}")
return True # if hiding fails, let it close normally
return False # cancel the close → app stays alive in tray
min_to_tray = minimize_to_tray()
def on_minimized():
# Default: a plain minimize just goes to the taskbar (do nothing —
# let the OS minimize normally). Only fold into the tray when the
# user explicitly chose `minimize-to: tray`.
if min_to_tray and use_tray and state["tray"] is not None and not state["quit"]:
try:
window.hide()
except Exception:
pass
window.events.closing += on_closing
try:
window.events.minimized += on_minimized
except Exception:
pass
try:
window.events.resized += on_resized
window.events.moved += on_moved
except Exception:
pass
if use_tray:
state["tray"] = start_tray(window, do_quit)
threading.Thread(target=_watch_show_flag, args=(window, win_open),
daemon=True).start()
else:
# Протокол rpwin («открой окно панели» с веб-кнопки) нужен и без
# треев: иначе кнопка зря ждёт 2.5 с и уезжает в standalone-окно.
def _watch_rpwin_only():
while win_open.is_set():
_rpwin_poll_once()
time.sleep(0.7)
threading.Thread(target=_watch_rpwin_only, daemon=True).start()
# Тестовый хук для проверок frozen-exe без человека: закрыть окно
# «самому через N секунд» — штатный do_quit со всего сценария «Выход».
sc = os.environ.get("RIPSTER_SELFCLOSE")
if sc and sc.strip().isdigit():
_log(f"[launcher] RIPSTER_SELFCLOSE={sc}: окно закроется само")
threading.Timer(int(sc.strip()), do_quit).start()
# ПОСТОЯННЫЙ ПРОФИЛЬ ОКНА. По умолчанию pywebview стартует в приватном
# режиме: куки и localStorage живут только до закрытия окна. Из-за этого
# после КАЖДОГО перезапуска Ripster снова просил пароль, хотя сессия
# живёт 30 дней и ключ подписи в config.yaml не меняется (владелец,
# 20.09.2026: «чтобы после рестарта не понадобилось входить»). Заодно
# сохраняются тема, скин и язык. Папка — рядом с данными пользователя,
# чтобы переустановка приложения её не стирала.
_profile = os.path.join(
os.environ.get("LOCALAPPDATA") or os.path.expanduser("~"),
"Ripster", "webview")
try:
os.makedirs(_profile, exist_ok=True)
except Exception as e: # noqa: BLE001
_log(f"[launcher] профиль окна недоступен ({e!r}) — приватный режим")
_profile = None
if _profile:
_log(f"[launcher] профиль окна: {_profile}")
webview.start(private_mode=False, storage_path=_profile)
else:
webview.start() # blocks until the window is destroyed (real quit)
WINDOW = None
SPLASH["active"] = False
_log("[launcher] webview window closed")
return "webview", state
except Exception as e:
import traceback
_log(f"[launcher] webview FAILED ({type(e).__name__}: {e}) -> browser fallback")
_log(traceback.format_exc())
webbrowser.open(url)
return "browser", None
def _sleep_stop(stop: "threading.Event | None", seconds: float) -> None:
"""Спать, но проснуться, если лончер сворачивают."""
if stop is None:
time.sleep(seconds)
return
stop.wait(seconds)
def _watchdog(name: str, is_alive, restart, win_open: threading.Event,
stop: "threading.Event | None" = None, max_fails: int = 8,
pause: float = 0.5, boot: "threading.Event | None" = None,
gate=None) -> None:
"""Один общий сторож. Сервер и бот перезапускаются одинаково: умер → пауза →
попытка → счётчик; различаются только `restart`. Счётчик обнуляется первой же
удачной проверкой «жив», так что лимит — это именно подряд идущие попытки, а
не всё время жизни процесса.
`boot` — событие «поток старта дошёл до запуска этого сервиса»: до него
процесс отсутствует по определению, и поднимать его с двух сторон было бы
двойным запуском.
`gate()` — можно ли вообще вмешиваться: снятый чекбокс, ручная остановка
кнопкой или идущее прямо сейчас действие делают надзор неверным — сторож
молча спит, пока гейт не откроется.
`is_alive()` и `restart()` вызываются из этого потока и не должны бросать
наружу: исключения логируются, цикл продолжается."""
fails = 0
while win_open.is_set():
if boot is not None and not boot.is_set():
_sleep_stop(stop, pause)
continue
if gate is not None and not gate():
fails = 0
_sleep_stop(stop, pause)
continue
try:
alive = bool(is_alive())
except Exception as e:
alive = True # сломанный пробник — не повод убивать рабочий сервис
_log(f"[watchdog:{name}] probe failed: {type(e).__name__}: {e}")
if alive:
fails = 0
_sleep_stop(stop, pause)
continue
if not win_open.is_set():
break
fails += 1
if fails > max_fails:
_log(f"[watchdog:{name}] {name} не поднимается за {max_fails} попыток — "
f"прекращаю, нужен ручной разбор (logs/)")
STATUS.set(name, BAD, t("st.dead"))
while stop is not None and not stop.is_set(): # ждём выхода, а не крутим пробник
stop.wait(60)
break
STATUS.set(name, WARN, t("st.restarting"))
_log(f"[watchdog:{name}] {name} exited → restart #{fails} (пауза {pause:g}с)")
try:
restart()
except Exception as e:
_log(f"[watchdog:{name}] restart failed: {type(e).__name__}: {e}")
STATUS.set(name, WARN, t("st.restarting"), restarted=True)
_sleep_stop(stop, pause)
_log(f"[watchdog:{name}] stopped")
def _gate(key: str) -> bool:
"""Сторож вмешивается, только когда компонент включён чекбоксом, лончер его
реально поднимал (WANTED) и никто прямо сейчас не жмёт ему кнопки. `TAKING`
закрывает окно, пока сторож ждёт освобождения порта: без него restart()
успевал породить сервер параллельно с ручной остановкой, и этот процесс
доставался лишь быкому `box[0]` — «убитый» сервер продолжал отвечать."""
return (bool(ENABLED.get(key)) and bool(WANTED.get(key)) and not BUSY.get(key)
and not TAKING.get(key))
@contextlib.contextmanager
def _taking(key: str):
"""Контекст «я прямо сейчас поднимаю/гашу `key`»: на это время сторож
отключён, а `box[0]` меняет только тот, кто внутри блока."""
TAKING[key] = True
try:
yield
finally:
TAKING[key] = False
def _supervise(box: list, port: int, win_open: threading.Event,
stop: "threading.Event | None" = None,
boot: "threading.Event | None" = None) -> None:
"""Сервер: прежнее присматривание (respawn ровно один раз на выход процесса,
с ожиданием освобождения порта) поверх общего `_watchdog`."""
def alive() -> bool:
proc = box[0]
return proc is not None and proc.poll() is None
def restart() -> None:
box[0] = None # труп больше никем не считается, и terminate его не тронет
# Let the old process fully release the port before rebinding.
for _ in range(40):
if not win_open.is_set() or port_free(port):
break
_sleep_stop(stop, 0.25)
if not win_open.is_set():
return
# Внутри TAKING свой же гейт закрыт, поэтому здесь проверяем только
# признаки «передумали»: ручная остановка (WANTED) или снятый чекбокс.
with _taking("server"):
if not WANTED.get("server") or not ENABLED.get("server"):
return
box[0] = start_server(port)
if box[0] is not None:
WANTED["server"] = True
# ожидание готовности — вне TAKING: кнопка ■ должна отвечать сразу
wait_for_ripster(port, timeout=30)
_watchdog("server", alive, restart, win_open, stop=stop, boot=boot,
gate=lambda: CTX.get("own") and _gate("server"))
def _supervise_bot(bot_box: list, hc, win_open: threading.Event,
stop: "threading.Event | None" = None,
boot: "threading.Event | None" = None) -> None:
"""Бот: `hc.bot_process_count()` нужен потому, что бот мог быть поднят не
нами (сторож healthcheck, руки владельца) — тогда handle'а нет, и единственный
способ узнать, жив ли он, это командная строка процесса. Интерпретатор берётся
оттуда же, откуда его запускает сторож, иначе «живой бот» и «наш бот» — разные
процессы."""
def alive() -> bool:
proc = bot_box[0]
if proc is not None and proc.poll() is None:
return True
try:
return hc.bot_process_count() > 0
except Exception:
return True
def restart() -> None:
bot_box[0] = None
with _taking("bot"):
if not WANTED.get("bot") or not ENABLED.get("bot"):
return
bot_box[0] = hc.start_bot_process(notify=_hc_log)
if bot_box[0] is None:
WANTED["bot"] = False # поднять нечем — не долбить лимит впустую
# 8с между попытками: бот, падающий на плохом токене, не должен выжигать CPU
# и плодить строки в logs/bot.log быстрее, чем человек успевает прочитать.
_watchdog("bot", alive, restart, win_open, stop=stop, boot=boot,
max_fails=5, pause=8.0,
gate=lambda: CTX.get("own") and not iso_blocked("bot") and _gate("bot"))
def _supervise_docker(hc, win_open: threading.Event,
stop: "threading.Event | None" = None) -> None:
"""Docker Desktop после ребута сам не поднимается, а на нём весь стек.
Пауза 20 с и три попытки: медленный старт движка — норма, а не повод
крутить `docker ps` вхолостую сотни раз."""
def alive() -> bool:
return bool(hc.docker_engine_up(timeout=8))
def restart() -> None:
if not hc.ensure_docker_engine(notify=_hc_log):
WANTED["docker"] = False # движок не ожил за ~2 мин — хватит долбить
_watchdog("docker", alive, restart, win_open, stop=stop,
max_fails=3, pause=20.0,
gate=lambda: _gate("docker") and not iso_blocked("docker"))
def _supervise_containers(hc, win_open: threading.Event,
stop: "threading.Event | None" = None) -> None:
"""Контейнеры: упал wrapper — поднят заново; лежит движок — очередь у
сторожа Docker, здесь сторож не мешает."""
def missing():
wanted = list(getattr(hc, "REQUIRED_CONTAINERS", ()))
running = hc.running_container_names()
return [c for c in wanted if c not in running]
def alive() -> bool:
if not hc.docker_engine_up(timeout=6):
return True
return not missing()
def restart() -> None:
_raise_containers(hc, missing())
_watchdog("containers", alive, restart, win_open, stop=stop,
max_fails=3, pause=45.0,
gate=lambda: _gate("containers") and not iso_blocked("containers"))
# ── Шаги подъёма/остановки: их же зовёт кнопка «Запустить всё», чекбоксы и
# одиночные ▶/■. Порядок docker → контейнеры → сервер → бот выдержан в
# bring_up_stack; каждый шаг сам ждёт готовности и сам рисует свой статус.
def _raise_containers(hc, missing: list) -> list:
"""Поднять список контейнеров; вернуть те, что так и не поднялись.
amd-wrapper мог быть не создан вообще (не «остановлен») — для него
единственный проверенный путь: пересоздание с логином (`_heal_wrapper`)."""
for c in missing:
hc.ensure_container(c, notify=_hc_log)
wanted = list(getattr(hc, "REQUIRED_CONTAINERS", ()))
running = hc.running_container_names()
still = [c for c in wanted if c not in running]
if "amd-wrapper" in missing and "amd-wrapper" in still:
hc._heal_wrapper()
still = [c for c in wanted if c not in hc.running_container_names()]
return still
def _up_docker(hc) -> bool:
if hc is None:
STATUS.set("docker", OFF, t("st.no_hc"))
return False
if hc.docker_engine_up(timeout=8):
WANTED["docker"] = True
STATUS.set("docker", OK, t("st.up"))
return True
_log("[stack] Docker-движок лежит → поднимаю Docker Desktop")
if hc.ensure_docker_engine(notify=_hc_log):
WANTED["docker"] = True
STATUS.set("docker", OK, t("st.up"), restarted=True)
return True
STATUS.set("docker", BAD, t("st.down"))
return False
def _up_containers(hc) -> bool:
if hc is None:
STATUS.set("containers", OFF, t("st.no_hc"))
return False
if not hc.docker_engine_up(timeout=6):
STATUS.set("containers", OFF, t("st.wait_docker"))
return False
wanted = list(getattr(hc, "REQUIRED_CONTAINERS", ()))
running = hc.running_container_names()
missing = [c for c in wanted if c not in running]
raised = False
if missing:
_log(f"[stack] контейнеров нет: {', '.join(missing)} → поднимаю")
still = _raise_containers(hc, missing)
raised = len(still) < len(missing)
else:
still = []
WANTED["containers"] = True
STATUS.set("containers",
OK if not still else (BAD if len(still) == len(wanted) else WARN),
t("st.all", n=len(wanted)) if not still
else t("st.missing", names=", ".join(still)),
restarted=raised)
return not still
def _leave_splash() -> None:
"""Окно ещё показывает «Запускаю Ripster…» — перевести его на живой сервер.
Поток `_poll_until_ready` мог уже отработать свой таймаут, поэтому старт
сервера из панели не может на него полагаться."""
if SPLASH["active"] and WINDOW is not None:
try:
WINDOW.load_url(SPLASH["url"])
SPLASH["active"] = False
_log("[launcher] окно переведено на сервер из панели")
except Exception as e:
_log(f"[launcher] load_url from panel failed: {type(e).__name__}: {e}")
def _up_server(port: int, box: list) -> bool:
if ripster_alive(port):
# На порту уже кто-то отвечает (attach-режим, чужой сервер): показываем
# «работает», но под надзор берём только свой процесс — «к чужому не лезем».
proc = box[0]
WANTED["server"] = proc is not None and proc.poll() is None
STATUS.set("server", OK, t("st.port", port=port))
_leave_splash()
return True
_log("[stack] стартую сервер")
with _taking("server"):
old = box[0]
if old is not None and old.poll() is None:
# наш старый процесс ещё жив: без terminate он пережил бы «остановку»
# и ответил на порту, а новым handle'ом box[0] бы не стал.
try:
old.terminate()
old.wait(15)
except Exception as e:
_log(f"[stack] old server terminate failed: {type(e).__name__}: {e}")
proc = start_server(port)
box[0] = proc
if proc is None:
# start_server уже объяснил причину (нет bundled python) и повторял её
# восемь раз не должен сторож: он поднимает только тот процесс,
# который хотя бы раз удалось запустить.
WANTED["server"] = False
STATUS.set("server", BAD, t("st.down"))
return False
CTX["own"] = True # подняли сами — теперь наш: и сторожим, и останавливаем
# Надзор с первого Popen: если сервер сдохнет, пока мы ждём /api/ping,
# сторож увидит это сразу, как только мы снимем TAKING.
WANTED["server"] = True
STATUS.set("server", WARN, t("st.starting"))
ready = wait_for_ripster(port, timeout=90)
if not ready:
WANTED["server"] = box[0] is not None and box[0].poll() is None
STATUS.set("server", OK if ready else BAD,
t("st.port", port=port) if ready else f"{port} · {t('st.down')}")
if ready:
_leave_splash()
return ready
def _up_bot(hc, bot_box: list) -> bool:
if hc is None:
STATUS.set("bot", OFF, t("st.no_hc"))
return False
if hc.bot_process_count() > 0:
WANTED["bot"] = True
STATUS.set("bot", OK, t("st.up"))
return True
_log("[stack] бот не запущен → поднимаю bot.py")
with _taking("bot"):
old = bot_box[0]
if old is not None and old.poll() is None:
try:
old.terminate()
old.wait(15)
except Exception as e:
_log(f"[stack] old bot terminate failed: {type(e).__name__}: {e}")
bot_box[0] = hc.start_bot_process(notify=_hc_log)
up = bot_box[0] is not None
WANTED["bot"] = up
STATUS.set("bot", OK if up else BAD, t("st.up") if up else t("st.down"),
restarted=up)
return up
def _down_docker(hc) -> None:
"""Стоп движка через сам Docker Desktop (`docker desktop stop`) — он гасит и
контейнеры, поэтому WANTED снимается у обоих. Насильно убивать процесс
«Docker Desktop.exe» не будем: это чужие контейнеры тоже."""
WANTED["docker"] = WANTED["containers"] = False
if hc is None:
return
rc, out = hc._docker("desktop", "stop")
if rc == 0 and not hc.docker_engine_up(timeout=6):
STATUS.set("docker", OFF, t("st.stopped"))
STATUS.set("containers", OFF, t("st.wait_docker"))
else:
STATUS.set("docker", BAD, t("st.fail", err=(out or "docker desktop stop")[:120]))
def _down_containers(hc) -> None:
WANTED["containers"] = False
if hc is None:
return
wanted = list(getattr(hc, "REQUIRED_CONTAINERS", ()))
for c in wanted:
try:
if c in hc.running_container_names():
hc._docker("stop", c)
_log(f"[stack] контейнер {c} остановлен кнопкой")
except Exception as e:
_log(f"[stack] stop {c} failed: {type(e).__name__}: {e}")
STATUS.set("containers", OFF, t("st.stopped"))
def _down_server(box: list, own: bool) -> None:
if not own:
STATUS.set("server", WARN, t("st.not_ours"))
return
WANTED["server"] = False # раньше terminate: иначе сторож воскреет труп
with _taking("server"):
proc = box[0]
box[0] = None
if proc is not None and proc.poll() is None:
try:
proc.terminate()
proc.wait(15)
except Exception as e:
_log(f"[stack] server terminate failed: {type(e).__name__}: {e}")
STATUS.set("server", OFF, t("st.stopped"))
_log("[stack] сервер остановлен кнопкой")
def _down_bot(bot_box: list) -> None:
WANTED["bot"] = False
with _taking("bot"):
proc = bot_box[0]
bot_box[0] = None
if proc is not None and proc.poll() is None:
try:
proc.terminate()
STATUS.set("bot", OFF, t("st.stopped"))
_log("[stack] бот остановлен кнопкой")
except Exception as e:
STATUS.set("bot", BAD, t("st.fail", err=str(e)[:120]))
return
hc = CTX.get("hc")
try:
external = hc is not None and hc.bot_process_count() > 0
except Exception:
external = False
if external:
# «К чужому не лезем» из _teardown: процесс поднимал не лончер —
# лончер его и не убивает, только снимает с надзора.
STATUS.set("bot", WARN, t("st.not_ours"))
else:
STATUS.set("bot", OFF, t("st.stopped"))
def _component_action(key: str, start: bool) -> None:
"""Одна кнопка панели. BUSY на время действия: сторож не подсуетится
параллельно, панель честно рисует «запускается/останавливаем»."""
if BUSY.get(key):
return
BUSY[key] = "start" if start else "stop"
render_panel()
hc, box, bot_box = CTX.get("hc"), CTX.get("box"), CTX.get("bot_box")
try:
if start:
{"docker": lambda: _up_docker(hc),
"containers": lambda: _up_containers(hc),
"server": lambda: _up_server(CTX.get("port"), box),
"bot": lambda: _up_bot(hc, bot_box)}[key]()
else:
{"docker": lambda: _down_docker(hc),
"containers": lambda: _down_containers(hc),
"server": lambda: _down_server(box, CTX.get("own")),
"bot": lambda: _down_bot(bot_box)}[key]()
except Exception as e:
import traceback
_log(f"[panel] {key} {'start' if start else 'stop'} failed: {type(e).__name__}: {e}")
_log(traceback.format_exc())
STATUS.set(key, BAD, t("st.fail", err=f"{type(e).__name__}"))
finally:
BUSY[key] = False
render_panel()
def _spawn(fn) -> None:
threading.Thread(target=fn, daemon=True).start()
def bring_up_stack(win_open: threading.Event,
stack_done: threading.Event) -> None:
"""«Запустить всё»: Docker → контейнеры → сервер → бот, по порядку, с
ожиданием готовности каждого; идут только включённые чекбоксы. Один и тот же
вызов звучит при старте Windows (фон за splash-окном) и по кнопке панели.
Снятые шаги `refresh_status` рисует честно (выключен/остановлен), а не
притворяется зеленым."""
try:
for key in ("docker", "containers", "server", "bot"):
if not win_open.is_set():
return
if not ENABLED.get(key) or iso_blocked(key):
continue
if key != "server" and CTX.get("hc") is None:
continue
_component_action(key, True)
finally:
# Сторожу бота можно начинать только после нас: иначе он поднял бы бота
# параллельно с нашим же запуском.
stack_done.set()
def _pid_alive(pid: "int | None") -> bool:
if not pid:
return False
try:
import ctypes
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
h = ctypes.windll.kernel32.OpenProcess(
PROCESS_QUERY_LIMITED_INFORMATION, False, int(pid))
if not h:
return False
ctypes.windll.kernel32.CloseHandle(h)
return True
except Exception:
return False
def _single_instance_guard() -> bool:
"""True = we are the sole instance and may proceed. False = another launcher
already owns the window; we signalled it to surface and should exit."""
global LOCK_FILE, SHOW_FLAG, WIN_STATE, RPWIN_REQ, RPWIN_ACK
logs = BASE / "logs"
# Изолированный прогон нового exe не должен отбирать lock у живого лончера
# (иначе «уже запущено → показать окно и выйти» убило бы тест до старта).
suffix = "_isolated" if isolated() else ""
LOCK_FILE = logs / f"launcher{suffix}.lock"
SHOW_FLAG = logs / f"launcher{suffix}.show"
RPWIN_REQ = logs / f"launcher{suffix}.rpwin"
RPWIN_ACK = logs / f"launcher{suffix}.rpwin.ok"
WIN_STATE = logs / f"window_state{suffix}.json"
try:
logs.mkdir(parents=True, exist_ok=True)
existing = None
if LOCK_FILE.exists():
try:
existing = int(LOCK_FILE.read_text(encoding="utf-8").strip())
except Exception:
existing = None
if existing and existing != os.getpid() and _pid_alive(existing):
_log(f"[launcher] already running (pid {existing}) → surfacing it, exiting")
try:
SHOW_FLAG.write_text("1", encoding="utf-8")
except Exception:
pass
return False
LOCK_FILE.write_text(str(os.getpid()), encoding="utf-8")
except Exception as e:
_log(f"[launcher] single-instance guard skipped: {type(e).__name__}: {e}")
return True
def _claim_install_mutex() -> None:
"""Hold a named mutex the INSTALLER checks (AppMutex=Global\\RipsterRunning).
Without it an install over a running Ripster silently failed: Ripster.exe and
python\\python.exe are locked, the files are not replaced, and the user ends up
still on the old build convinced they upgraded. Now Inno Setup detects the
running app up front and asks to close it. Purely advisory — any failure here
must never stop the launcher."""
if os.name != "nt":
return
try:
import ctypes
ctypes.windll.kernel32.CreateMutexW(None, False, "Global\\RipsterRunning")
except Exception as e:
_log(f"[launcher] install-mutex skipped: {type(e).__name__}: {e}")
# ── Автозапуск: задача Планировщика Windows ───────────────────────────────────
# `uac_admin=True` в spec'e означает, что лончеру и так нужен административный
# токен, поэтому обычный ярлык в «Автозагрузка» показывал бы запрос UAC при каждом
# входе. Задача с RunLevel Highest и LogonType Interactive возвышает сама
# Планировщик — окно появляется без единого диалога.
# `schtasks /Query` на русской Windows печатает локализованные имена полей, так что
# состояние читаем PowerShell-cmdlet'ами: `.State` — это enum, он не переводится.
_AUTOSTART_CACHE = [0.0, "unknown"] # [когда обновляли, состояние]
def autostart_task_name() -> str:
"""Имя задачи зависит от того, КАКОЙ exe её ставит: рабочий лончер
(Ripster.exe / RipsterLauncher.exe) → «Ripster Autostart», всё остальное
(Ripster_new.exe, прогон из исходников) → тестовая. Иначе проверка нового exe
переписала бы задачу живого автозапуска."""
exe = Path(sys.executable).name if getattr(sys, "frozen", False) else Path(__file__).name
stem = exe.rsplit(".", 1)[0].lower()
if stem.startswith("ripster") and not (stem.endswith("_new") or stem.endswith("_test")):
return "Ripster Autostart"
return "Ripster Autostart (test)"
def autostart_target() -> tuple[str, str, str]:
"""(что запускать, аргументы, рабочая папка). В исходниках задача указывает
на pythonw — с python.exe при входе в систему моргнула бы консоль."""
if getattr(sys, "frozen", False):
return sys.executable, "", str(BASE)
w = Path(sys.executable).with_name("pythonw.exe")
py = str(w) if w.exists() else sys.executable
return py, str(Path(__file__).resolve()), str(BASE)
def _ps(script: str, timeout: float = 60) -> tuple[int, str]:
"""PowerShell без окна консоли и с UTF-8 в выводе: иначе текст ошибки на
русской Windows (cp866) приезжает кашей и в логе его не прочитать."""
prelude = "[Console]::OutputEncoding=[Text.Encoding]::UTF8; $OutputEncoding=[Text.Encoding]::UTF8\n"
try:
r = subprocess.run(
["powershell", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass",
"-Command", prelude + script],
capture_output=True, timeout=timeout, creationflags=CREATE_NO_WINDOW)
out = (r.stdout or b"").decode("utf-8", "replace").strip()
err = (r.stderr or b"").decode("utf-8", "replace").strip()
return r.returncode, "\n".join(x for x in (out, err) if x)
except Exception as e:
return -1, f"{type(e).__name__}: {e}"
def _q(s: str) -> str:
return str(s).replace("'", "''") # одинарные кавычки внутри PS-'...'
# Кто именно «я» для Планировщика: `$env:USERDOMAIN + '\' + $env:USERNAME` в
# повышенном процессе иногда даёт «имя пользователя или группа не найдены»
# (14,8):UserId — задача тогда не создаётся вовсе. SID из токена не требует
# поиска по имени и принимается `-UserId` напрямую.
def current_user_sid() -> str:
try:
import ctypes
import ctypes.wintypes as wt
tok = wt.HANDLE()
if not ctypes.windll.advapi32.OpenProcessToken(
wt.HANDLE(-1), 0x0008, ctypes.byref(tok)): # TOKEN_QUERY
return ""
size = wt.DWORD(0)
ctypes.windll.advapi32.GetTokenInformation(tok, 1, None, 0, ctypes.byref(size))
buf = (ctypes.c_char * size.value)()
if not ctypes.windll.advapi32.GetTokenInformation(
tok, 1, buf, size, ctypes.byref(size)):
return ""
sid = ctypes.cast(buf, ctypes.POINTER(ctypes.c_void_p)).contents.value
strp = wt.LPWSTR()
if not ctypes.windll.advapi32.ConvertSidToStringSidW(ctypes.c_void_p(sid),
ctypes.byref(strp)):
return ""
return strp.value or "" # LPWSTR сам разыменовывается в str
except Exception as e:
_log(f"[autostart] sid lookup failed: {type(e).__name__}: {e}")
return ""
def autostart_state(force: bool = False) -> str:
"""missing | disabled | ready | running | queued | unknown (кэш 20 с — пункт
меню перечитывается на каждом обновлении трея, а PowerShell стоит секунду)."""
now = time.time()
if not force and now - _AUTOSTART_CACHE[0] < 20:
return _AUTOSTART_CACHE[1]
rc, out = _ps("$t = Get-ScheduledTask -TaskName '{n}' -ErrorAction SilentlyContinue; "
"if ($t) {{ $t.State }} else {{ 'Missing' }}".format(n=_q(autostart_task_name())))
state = out.strip().lower() if rc == 0 else "unknown"
if rc != 0 or state not in ("missing", "disabled", "ready", "running", "queued"):
state = state or "unknown"
_AUTOSTART_CACHE[0] = now
_AUTOSTART_CACHE[1] = state
return state
def autostart_installed() -> bool:
return autostart_state() != "missing"
def autostart_enabled() -> bool:
return autostart_state() in ("ready", "running", "queued")
def autostart_install(enable: bool = True) -> tuple[bool, str]:
"""Зарегистрировать (или переписать) задачу «вход в систему → лончер».
Идмпотентно: -Force перезаписывает существующую, повторный клик ничего не ломает.
-ExecutionTimeLimit: по умолчанию у задачи лимит 72 часа, и Планировщик убил бы
лончер на третьи сутки работы — отсюда практически бесконечный порог."""
exe, arg, wd = autostart_target()
name = autostart_task_name()
who = current_user_sid() or (os.environ.get("USERDOMAIN", "") + "\\"
+ os.environ.get("USERNAME", ""))
argument = f" -Argument '{_q(arg)}'" if arg else ""
disable = "" if enable else "\nDisable-ScheduledTask -TaskName $name | Out-Null"
script = f"""
$ErrorActionPreference = 'Stop'
$name = '{_q(name)}'
$action = New-ScheduledTaskAction -Execute '{_q(exe)}'{argument} -WorkingDirectory '{_q(wd)}'
$trigger = New-ScheduledTaskTrigger -AtLogOn
$principal = New-ScheduledTaskPrincipal -UserId '{_q(who)}' -LogonType Interactive -RunLevel Highest
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -MultipleInstances IgnoreNew -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit (New-TimeSpan -Days 3650)
Register-ScheduledTask -TaskName $name -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null{disable}
'ok'
"""
rc, out = _ps(script)
ok = rc == 0 and "ok" in out.lower()
_AUTOSTART_CACHE[0] = 0.0
_log(f"[autostart] install {name!r} as {who} → {'ok' if ok else 'FAIL'}: {out[:600]}")
return ok, out[:600]
def autostart_set_enabled(on: bool) -> tuple[bool, str]:
"""Переключатель из меню трея: выключил — задача остаётся зарегистрированной
и отключена (включить можно обратно без пересоздания)."""
if autostart_state(force=True) == "missing":
if not on:
return True, ""
return autostart_install(enable=True)
rc, out = _ps(
("Enable" if on else "Disable") + f"-ScheduledTask -TaskName '{_q(autostart_task_name())}' "
"-ErrorAction Stop | Out-Null; 'ok'")
ok = rc == 0 and "ok" in out.lower()
_AUTOSTART_CACHE[0] = 0.0
_log(f"[autostart] {'enable' if on else 'disable'} → {'ok' if ok else 'FAIL'}: {out[:200]}")
return ok, out[:200]
def autostart_remove() -> tuple[bool, str]:
rc, out = _ps(f"Unregister-ScheduledTask -TaskName '{_q(autostart_task_name())}' "
"-Confirm:$false -ErrorAction Stop; 'ok'")
ok = rc == 0 and "ok" in out.lower()
_AUTOSTART_CACHE[0] = 0.0
_log(f"[autostart] remove → {'ok' if ok else 'FAIL'}: {out[:200]}")
return ok, out[:200]
def autostart_report(action: str, ok: bool, detail: str = "") -> None:
"""console=False — stdout у frozen-exe нет, поэтому результат CLI-режима ещё
и в файл: так проверку «задача поставлена/отключена» видно без человека."""
payload = {"action": action, "ok": ok, "task": autostart_task_name(),
"state": autostart_state(force=True), "target": autostart_target()[0],
"detail": detail[:400], "when": time.strftime("%Y-%m-%d %H:%M:%S")}
try:
(BASE / "logs").mkdir(parents=True, exist_ok=True)
(BASE / "logs" / "autostart_last.json").write_text(
json.dumps(payload, ensure_ascii=False, indent=1), encoding="utf-8")
except Exception as e:
_log(f"[autostart] report write failed: {e}")
_echo(json.dumps(payload, ensure_ascii=False))
def handle_autostart_cli() -> bool:
"""Обслужить автозапусчные флаги. True = работать дальше нельзя, выходить."""
args = set(sys.argv[1:])
if "--autostart-status" in args:
autostart_report("status", True)
return True
for flag, action in (("--install-autostart", "install"),
("--disable-autostart", "disable"),
("--enable-autostart", "enable"),
("--remove-autostart", "remove")):
if flag not in args:
continue
if action == "install":
ok, detail = autostart_install(enable=False) # тестовую задачу не включаем
elif action == "remove":
ok, detail = autostart_remove()
else:
ok, detail = autostart_set_enabled(action == "enable")
autostart_report(action, ok, detail)
return True
return False
def _teardown(box: list, bot_box: list, mode: str) -> None:
"""При выходе из лончера останавливаем ТО, что пустили сами: сервер и бота.
К чужому не лезем — подключились к живому 7799 или бота поднял сторож, значит
его и не нам гасить; закрытая вкладка браузера (mode="browser") тоже не повод
ронять сервис."""
if mode != "webview":
return
# Снимаем надзор до terminate: иначе сторож увидит «упал» и поднимет процесс
# уже после того, как лончер вышел.
for key in ("server", "bot"):
WANTED[key] = False
TAKING[key] = True
try:
for name, holder in (("server", box), ("bot", bot_box)):
proc = holder[0]
holder[0] = None
if proc is None:
continue
try:
proc.terminate()
_log(f"[launcher] {name} остановлен (terminate)")
except Exception as e:
_log(f"[launcher] {name} terminate failed: {type(e).__name__}: {e}")
finally:
for key in ("server", "bot"):
TAKING[key] = False
def main() -> None:
if handle_autostart_cli():
return
_claim_install_mutex()
if not _single_instance_guard():
return
load_ui_prefs() # чекбоксы компонентов живут рядом с геометрией окна
desired = config_port()
hc = healthcheck_module()
iso = isolated()
box = [None] # box[0] = the server process WE own (None when attaching to an existing one)
bot_box = [None] # то же про bot.py: None, если его поднял не лончер
win_open = threading.Event()
win_open.set()
stop = threading.Event()
stack_done = threading.Event()
if ripster_alive(desired):
# OUR Ripster is already running here (e.g. user relaunched) → just attach.
# Ничего не перезапускаем и не останавливаем за собой: владелец — тот,
# кто сервер поднял.
port = desired
own = False
ready = True
stack_done.set()
_log(f"[launcher] Ripster already live on {port} — attaching")
else:
# Free port at/after the desired one — auto-skips a busy 7799 so a clean
# install never collides with another app (or another project's server).
port = find_free_port(desired)
if port != desired:
_log(f"[launcher] port {desired} busy → using {port}")
own = True
ready = False
# CTX ДО.spawn: поток «запустить всё» читает box/port/hc через
# _component_action → CTX.get(...), и если он успевает до update(),
# _up_server получает box=None и падает на box[0] = ...
CTX.update(hc=hc, port=port, box=box, bot_box=bot_box, own=own, iso=iso,
win_open=win_open, stop=stop, stack_done=stack_done)
# Старт после входа в Windows = тот же «Запустить всё», что и кнопка:
# тот же порядок, та же готовность каждого шага, те же чекбоксы.
threading.Thread(target=bring_up_stack, args=(win_open, stack_done),
daemon=True).start()
CTX.update(hc=hc, port=port, box=box, bot_box=bot_box, own=own, iso=iso,
win_open=win_open, stop=stop, stack_done=stack_done)
if own:
threading.Thread(target=_supervise,
args=(box, port, win_open, stop),
kwargs={"boot": stack_done}, daemon=True).start()
if not iso:
threading.Thread(target=_supervise_bot,
args=(bot_box, hc, win_open, stop),
kwargs={"boot": stack_done}, daemon=True).start()
else:
_log("[launcher] изолированный режим: бота не запускаю и за ним не слежу")
if hc is not None and not iso:
threading.Thread(target=_supervise_docker, args=(hc, win_open, stop),
daemon=True).start()
threading.Thread(target=_supervise_containers, args=(hc, win_open, stop),
daemon=True).start()
threading.Thread(target=_status_loop,
args=(hc, port, stack_done, win_open, stop),
daemon=True).start()
url = f"http://127.0.0.1:{port}"
mode, _state = open_window(url, port, win_open, ready=ready) # blocks until real quit
# Window destroyed (user chose Выход) → stop supervising FIRST (so it doesn't
# respawn), then stop the children we own.
win_open.clear()
stop.set()
for key in COMPONENTS: # после «Выход» никто никого не оживляет
WANTED[key] = False
_teardown(box, bot_box, mode)
try:
if LOCK_FILE is not None and LOCK_FILE.exists():
LOCK_FILE.unlink()
except Exception:
pass
if __name__ == "__main__":
main()